diff --git a/demo/ap-memory-agent/.env.example b/demo/ap-memory-agent/.env.example new file mode 100644 index 00000000..55dc2f17 --- /dev/null +++ b/demo/ap-memory-agent/.env.example @@ -0,0 +1,5 @@ +ANTHROPIC_API_KEY=your_anthropic_key +EVERMEM_BASE_URL=http://localhost:1995 +EVERMEM_USER_ID=ap_agent_system +# Required for seeded vendor history (matches ap_agent_system's group) +EVERMEM_GROUP_ID=eb6618c4d52d3bf9_group diff --git a/demo/ap-memory-agent/README.md b/demo/ap-memory-agent/README.md new file mode 100644 index 00000000..5116a9be --- /dev/null +++ b/demo/ap-memory-agent/README.md @@ -0,0 +1,126 @@ +# AP Memory Agent + +Accounts Payable automation agent built with **LangGraph + EverMemOS + Claude** for the [Memory Genesis Competition 2026](https://luma.com/n88icl03?tk=aawakR). + +**Core demo moment:** An agent catches a fraudulent or duplicate invoice because it remembers what happened weeks ago. + +## Demo Video + + + + + +[Watch the full demo on Loom](https://www.loom.com/share/9834fe7b5fca45c78c304f7101b15ac6) + +## Architecture + +``` +Streamlit UI (invoice form) + ↓ +LangGraph Orchestrator + ├── Node 1: invoice_agent → normalise invoice, write episodic_memory, fetch vendor context + ├── Node 2: risk_agent → Claude reasons over invoice + memory context, returns risk flags + ├── Node 3: approval_agent → Claude makes approve/hold/reject decision with reasoning + └── Node 4: memory_updater → write resolution to episodic_memory, update profile if risk ≥ 40 + ↕ ↕ ↕ ↕ + EverMemOS (Shared Memory Bus) +``` + +### Memory Strategy + +- **Invoice Agent:** Writes each invoice event as episodic memory; searches episodic + event_log (agentic retrieval); fetches profile separately; merges into `memory_context`. +- **Memory Updater:** Always writes resolution as episodic memory; conditionally writes profile when risk_score ≥ 40 or decision is hold/reject +- **Profile** is not searchable — fetched via GET /memories and merged manually + +## Setup + +1. **Prerequisites** + - Python 3.11+ + - EverMemOS running locally (default: `http://localhost:1995`) + +2. **Install dependencies** + ```bash + pip install -r requirements.txt + ``` + +3. **Configure environment** + ```bash + cp .env.example .env + # Edit .env with your ANTHROPIC_API_KEY + # EVERMEM_GROUP_ID is required for seeded vendor history + ``` + +4. **Seed vendor history** + ```bash + cd demo/ap-memory-agent + python seed_memory.py + # Or: python seed_memory.py --clear # Clear and re-seed + ``` + From EverMemOS root: ` + ` + + **Extraction timing:** EverMemOS uses boundary detection — messages are extracted into episodic memories when a "conversation episode" is complete. The seed sends user+assistant pairs with `sync_mode` to trigger extraction. Expect 1–2 minutes for the full seed. Episodic memories appear after boundary detection runs. + +5. **Run the UI** + ```bash + cd demo/ap-memory-agent + streamlit run streamlit_app.py + ``` + From EverMemOS root: `uv run streamlit run demo/ap-memory-agent/streamlit_app.py` + +## Demo Script (for judges) + +1. Run `seed_memory.py` to populate vendor history +2. Open Streamlit UI +3. **Acme Corp** — Submit invoice `INV-2025-0042` for $500 → should **APPROVE** (clean history) +4. **Globex Supplies** — Submit invoice `INV-2025-0105` for $1,200 → should **HOLD** (prior dispute) +5. **Shadow LLC** — Submit invoice `INV-777` for $3,000 → should **REJECT** (duplicate caught by memory) + +## Test Examples + +### Streamlit UI (manual form) + +| Test | Vendor Name | Invoice Number | Amount | Expected | +|------|-------------|----------------|--------|----------| +| 1 | Acme Corp | INV-2025-0042 | 500 | APPROVE | +| 2 | Globex Supplies | INV-2025-0105 | 1200 | HOLD | +| 3 | Shadow LLC | INV-777 | 3000 | REJECT | +| 4 | Acme Corp | INV-2025-0099 | 550 | APPROVE | + +### CLI test script + +```bash +uv run python demo/ap-memory-agent/test_demo.py +``` + +Runs all 4 test invoices through the graph and prints decisions. + +## Project Structure + +``` +ap-memory-agent/ +├── .env # API keys (never commit) +├── .env.example # Template for env vars +├── requirements.txt # Dependencies +├── ap_agent_graph.py # LangGraph pipeline (4 nodes) +├── evermem_client.py # EverMemOS API wrapper +├── seed_memory.py # Pre-populate vendor history +├── streamlit_app.py # Streamlit UI +└── README.md +``` + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ANTHROPIC_API_KEY` | Claude API key | required | +| `EVERMEM_BASE_URL` | EverMemOS URL | `http://localhost:1995` | +| `EVERMEM_USER_ID` | Memory scope | `ap_agent_system` | +| `EVERMEM_GROUP_ID` | Group for seeded data | `eb6618c4d52d3bf9_group` | + +## EverMemOS API Notes + +- **POST /api/v1/memories** — Store message (MemorizeMessageRequest: message_id, create_time, sender, content, role) +- **GET /api/v1/memories** — Fetch by memory_type (profile, episodic_memory, etc.) +- **GET /api/v1/memories/search** — Search episodic_memory, event_log, foresight (profile NOT supported) +- **retrieve_method="agentic"** — Use for agent queries (LLM-guided retrieval) diff --git a/demo/ap-memory-agent/ap_agent_graph.py b/demo/ap-memory-agent/ap_agent_graph.py new file mode 100644 index 00000000..d1fb9aab --- /dev/null +++ b/demo/ap-memory-agent/ap_agent_graph.py @@ -0,0 +1,369 @@ +""" +AP Automation Agent - LangGraph + EverMemOS + Claude +===================================================== +Multi-agent accounts payable pipeline with shared episodic memory. + +Architecture: + UI (Streamlit) → LangGraph Orchestrator → [Invoice Agent → Risk Agent → Approval Agent → Memory Updater] + ↕ ↕ ↕ ↕ + EverMemOS (Shared Memory Bus) +""" + +import os +import json +import re +import uuid +from datetime import datetime +from typing import Any, TypedDict +from dotenv import load_dotenv + +from langgraph.graph import StateGraph, END +from langchain_anthropic import ChatAnthropic +from langchain_core.messages import HumanMessage, SystemMessage + +from evermem_client import EverMemClient + +load_dotenv() + + +def _extract_json(text: str) -> dict[str, Any] | None: + """ + Extract JSON from Claude response. Handles markdown code blocks and extra text. + """ + if not text or not isinstance(text, str): + return None + text = text.strip() + # Strip markdown code blocks (```json ... ``` or ``` ... ```) + match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text) + if match: + text = match.group(1).strip() + # Find first { and last } to extract JSON object + start = text.find("{") + end = text.rfind("}") + if start != -1 and end != -1 and end > start: + try: + return json.loads(text[start : end + 1]) + except json.JSONDecodeError: + pass + # Fallback: try parsing the whole string + try: + return json.loads(text) + except json.JSONDecodeError: + return None + + +# ───────────────────────────────────────────── +# GRAPH STATE +# ───────────────────────────────────────────── + +class APState(TypedDict): + invoice: dict + memory_context: str + risk_flags: list[str] + risk_score: int + decision: str + reasoning: str + invoice_id: str + processed_at: str + + +# ───────────────────────────────────────────── +# INITIALISE SHARED SERVICES +# ───────────────────────────────────────────── + +llm = ChatAnthropic( + model="claude-opus-4-6", + api_key=os.getenv("ANTHROPIC_API_KEY"), + temperature=0, +) + +evermem = EverMemClient() + + +# ───────────────────────────────────────────── +# NODE 1: INVOICE AGENT +# ───────────────────────────────────────────── + +def invoice_agent(state: APState) -> APState: + invoice = state["invoice"] + invoice_id = str(uuid.uuid4())[:8] + vendor = invoice.get("vendor_name", "Unknown Vendor") + + print(f"\n[Invoice Agent] Processing invoice from {vendor}...") + + memory_content = ( + f"Invoice received from vendor '{vendor}'. " + f"Invoice number: {invoice.get('invoice_number')}. " + f"Amount: ${invoice.get('amount')}. " + f"Payment terms: {invoice.get('payment_terms', 'net-30')}. " + f"Due date: {invoice.get('due_date', 'not specified')}. " + f"Line items: {invoice.get('line_items', 'not provided')}." + ) + evermem.write_memory( + content=memory_content, + role="assistant", + memory_type="episodic_memory", + ) + + vendor_memories = evermem.search_memories( + query=f"{vendor} invoice payment dispute late rejected duplicate", + retrieve_method="keyword", + memory_types=["episodic_memory", "event_log"], + top_k=20, + ) + + # Filter search results to this vendor (keyword search returns all groups) + vendor_lower = vendor.lower() + vendor_memories = [ + m for m in vendor_memories + if vendor_lower in str(m.get("content", m.get("episode", m.get("summary", m.get("subject", m.get("title", "")))))).lower() + ] + + vendor_profile = evermem.fetch_memories_by_type( + memory_types=["profile"], + top_k=3, + ) + + all_memories = vendor_profile + vendor_memories + memory_context = evermem.format_memory_context(all_memories) + print( + f"[Invoice Agent] Retrieved {len(vendor_profile)} profile + " + f"{len(vendor_memories)} episodic records for {vendor}" + ) + + return { + **state, + "invoice_id": invoice_id, + "memory_context": memory_context, + "processed_at": datetime.now().isoformat(), + } + + +# ───────────────────────────────────────────── +# NODE 2: RISK AGENT +# ───────────────────────────────────────────── + +def risk_agent(state: APState) -> APState: + invoice = state["invoice"] + memory_context = state["memory_context"] + + print(f"\n[Risk Agent] Analysing risk for invoice {invoice.get('invoice_number')}...") + + system_prompt = """You are an AP Risk Agent for a finance team. +Your job is to analyse incoming invoices against vendor payment history and flag anomalies. +Be precise, concise, and always ground your findings in the memory context provided. + +CRITICAL: Return ONLY a raw JSON object. No markdown, no code blocks, no preamble, no explanation. +Start your response with { and end with }.""" + + user_prompt = f""" +CURRENT INVOICE: +{json.dumps(invoice, indent=2)} + +VENDOR MEMORY (past interactions retrieved from shared memory system): +{memory_context} + +Analyse this invoice and return a JSON object with exactly this structure: +{{ + "risk_flags": ["list of specific risk flags, or empty list if clean"], + "risk_score": , + "summary": "one sentence summary of your finding" +}} + +Check for: +- Duplicate invoice numbers vs history +- Amount significantly higher than vendor average +- Unusual or changed payment terms +- Prior disputes or late payments with this vendor +- Any other anomalies in the memory context +""" + + response = llm.invoke([ + SystemMessage(content=system_prompt), + HumanMessage(content=user_prompt), + ]) + + content = response.content if isinstance(response.content, str) else str(response.content) + result = _extract_json(content) + if result is not None: + risk_flags = result.get("risk_flags", []) + risk_score = int(result.get("risk_score", 0)) + print(f"[Risk Agent] Score: {risk_score}/100 | Flags: {risk_flags or 'None'}") + else: + risk_flags = ["Unable to parse risk analysis — manual review required"] + risk_score = 50 + print("[Risk Agent] Warning: Could not parse Claude response as JSON") + + return { + **state, + "risk_flags": risk_flags, + "risk_score": risk_score, + } + + +# ───────────────────────────────────────────── +# NODE 3: APPROVAL AGENT +# ───────────────────────────────────────────── + +def approval_agent(state: APState) -> APState: + invoice = state["invoice"] + risk_flags = state["risk_flags"] + risk_score = state["risk_score"] + memory_context = state["memory_context"] + + print(f"\n[Approval Agent] Making decision (risk score: {risk_score})...") + + system_prompt = """You are a senior AP approver. You make final payment decisions +based on invoice data, risk analysis, and vendor history. +Be decisive and explain your reasoning in plain English for the finance team. + +CRITICAL: Return ONLY a raw JSON object. No markdown, no code blocks, no preamble. +Start your response with { and end with }.""" + + user_prompt = f""" +INVOICE: {json.dumps(invoice, indent=2)} + +RISK ANALYSIS: +- Risk Score: {risk_score}/100 +- Flags Raised: {risk_flags if risk_flags else "None"} + +VENDOR HISTORY CONTEXT: +{memory_context} + +Decision rules: +- risk_score 0-30 → "approve" (unless a critical flag overrides) +- risk_score 31-60 → "hold" (needs human review) +- risk_score 61+ → "reject" (do not process) +- Any duplicate invoice number → always "reject" +- Any unresolved prior dispute → always "hold" + +Return JSON: +{{ + "decision": "approve" | "hold" | "reject", + "reasoning": "2-3 sentence plain English explanation citing specific memory evidence" +}} +""" + + response = llm.invoke([ + SystemMessage(content=system_prompt), + HumanMessage(content=user_prompt), + ]) + + content = response.content if isinstance(response.content, str) else str(response.content) + result = _extract_json(content) + if result is not None: + decision = result.get("decision", "hold") + reasoning = result.get("reasoning", "No reasoning provided.") + if decision not in ("approve", "hold", "reject"): + decision = "hold" + else: + decision = "hold" + reasoning = "Could not parse approval decision — defaulting to hold for manual review." + + print(f"[Approval Agent] Decision: {decision.upper()}") + print(f"[Approval Agent] Reasoning: {reasoning}") + + return { + **state, + "decision": decision, + "reasoning": reasoning, + } + + +# ───────────────────────────────────────────── +# NODE 4: MEMORY UPDATER +# ───────────────────────────────────────────── + +def memory_updater(state: APState) -> APState: + invoice = state["invoice"] + vendor = invoice.get("vendor_name", "Unknown Vendor") + decision = state["decision"] + reasoning = state["reasoning"] + risk_flags = state["risk_flags"] + + print(f"\n[Memory Updater] Writing outcome to EverMemOS...") + + outcome_memory = ( + f"Invoice {invoice.get('invoice_number')} from vendor '{vendor}' " + f"for ${invoice.get('amount')} was {decision.upper()}. " + f"Risk score: {state['risk_score']}/100. " + f"Flags: {', '.join(risk_flags) if risk_flags else 'none'}. " + f"Reasoning: {reasoning}" + ) + evermem.write_memory( + content=outcome_memory, + role="assistant", + memory_type="episodic_memory", + ) + + if state["risk_score"] >= 40 or decision in ["hold", "reject"]: + profile_update = ( + f"Vendor '{vendor}' has a flagged invoice history. " + f"Most recent outcome: {decision.upper()} on invoice " + f"{invoice.get('invoice_number')} (${invoice.get('amount')}). " + f"Risk flags observed: {', '.join(risk_flags) if risk_flags else 'none'}." + ) + evermem.write_vendor_profile(vendor=vendor, profile_content=profile_update) + print(f"[Memory Updater] Vendor profile updated due to risk score {state['risk_score']}") + + print("[Memory Updater] Resolution written to shared memory ✓") + return state + + +# ───────────────────────────────────────────── +# ROUTING & GRAPH +# ───────────────────────────────────────────── + +def route_after_approval(state: APState) -> str: + return "memory_updater" + + +def build_ap_graph() -> StateGraph: + graph = StateGraph(APState) + + graph.add_node("invoice_agent", invoice_agent) + graph.add_node("risk_agent", risk_agent) + graph.add_node("approval_agent", approval_agent) + graph.add_node("memory_updater", memory_updater) + + graph.set_entry_point("invoice_agent") + graph.add_edge("invoice_agent", "risk_agent") + graph.add_edge("risk_agent", "approval_agent") + graph.add_conditional_edges("approval_agent", route_after_approval, { + "memory_updater": "memory_updater", + }) + graph.add_edge("memory_updater", END) + + return graph.compile() + + +# ───────────────────────────────────────────── +# CLI ENTRY POINT +# ───────────────────────────────────────────── + +if __name__ == "__main__": + print("Running CLI test...") + graph = build_ap_graph() + test_invoice = { + "vendor_name": "Acme Corp", + "invoice_number": "INV-2025-0042", + "amount": 12500.00, + "payment_terms": "net-30", + "due_date": "2025-03-30", + "line_items": "Software licenses x5", + } + initial_state: APState = { + "invoice": test_invoice, + "memory_context": "", + "risk_flags": [], + "risk_score": 0, + "decision": "", + "reasoning": "", + "invoice_id": "", + "processed_at": "", + } + result = graph.invoke(initial_state) + print(f"\n{'='*50}") + print(f"FINAL DECISION: {result['decision'].upper()}") + print(f"REASONING: {result['reasoning']}") + print(f"RISK SCORE: {result['risk_score']}/100") diff --git a/demo/ap-memory-agent/data/ap_agent_seed.json b/demo/ap-memory-agent/data/ap_agent_seed.json new file mode 100644 index 00000000..f5c7bb59 --- /dev/null +++ b/demo/ap-memory-agent/data/ap_agent_seed.json @@ -0,0 +1,38 @@ +{ + "version": "1.0.0", + "conversation_meta": { + "scene": "assistant", + "scene_desc": {"description": "AP Agent vendor history for demo"}, + "name": "AP Agent Vendor History", + "description": "Vendor invoice history: Acme Corp (clean), Globex Supplies (dispute), Shadow LLC (duplicate).", + "group_id": "eb6618c4d52d3bf9_group", + "created_at": "2025-01-15T00:00:00Z", + "default_timezone": "UTC", + "user_details": { + "ap_agent_user": {"full_name": "AP User", "role": "user", "extra": {}}, + "ap_agent_system": {"full_name": "AP Agent", "role": "assistant", "extra": {}} + }, + "tags": ["AP", "Vendor", "Invoice"] + }, + "conversation_list": [ + {"message_id": "ap_seed_001", "create_time": "2025-01-15T10:00:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "What was the outcome of invoice INV-001 from Acme Corp?", "refer_list": []}, + {"message_id": "ap_seed_002", "create_time": "2025-01-15T10:00:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Invoice INV-001 from Acme Corp for $500 approved. Net 30 terms. Paid on time. Line items: Office supplies.", "refer_list": []}, + {"message_id": "ap_seed_003", "create_time": "2025-01-15T10:01:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "And invoice INV-002?", "refer_list": []}, + {"message_id": "ap_seed_004", "create_time": "2025-01-15T10:01:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Invoice INV-002 from Acme Corp for $450 approved. Net 30. Paid within terms. Software licenses.", "refer_list": []}, + {"message_id": "ap_seed_005", "create_time": "2025-01-15T10:02:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "INV-003, INV-004, INV-005 from Acme?", "refer_list": []}, + {"message_id": "ap_seed_006", "create_time": "2025-01-15T10:02:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Invoice INV-003 from Acme Corp for $550 approved. INV-004 for $480 approved. INV-005 for $520 approved. Net 30. Clean payment history. Acme Corp is low-risk: 5 invoices approved in past 6 months, amounts $400-$600, no disputes.", "refer_list": []}, + + {"message_id": "ap_seed_007", "create_time": "2025-01-16T10:05:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "What about Globex Supplies invoices INV-101 and INV-102?", "refer_list": []}, + {"message_id": "ap_seed_008", "create_time": "2025-01-16T10:05:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Invoice INV-101 from Globex Supplies for $1,200 approved. INV-102 for $980 approved. Net 30. Paid on time.", "refer_list": []}, + {"message_id": "ap_seed_009", "create_time": "2025-01-16T10:06:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "Was there a dispute with Globex?", "refer_list": []}, + {"message_id": "ap_seed_010", "create_time": "2025-01-16T10:06:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Invoice INV-103 from Globex Supplies disputed March 2025 — shipment delay. Resolution: partial credit issued. Amount $2,000.", "refer_list": []}, + {"message_id": "ap_seed_011", "create_time": "2025-01-16T10:07:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "Any late payments from Globex?", "refer_list": []}, + {"message_id": "ap_seed_012", "create_time": "2025-01-16T10:07:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Invoice INV-104 from Globex Supplies for $1,500 paid 15 days late. Net 30 terms. Globex has prior dispute and late payment. Moderate risk.", "refer_list": []}, + + {"message_id": "ap_seed_013", "create_time": "2025-01-17T10:10:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "What happened with Shadow LLC invoice INV-777?", "refer_list": []}, + {"message_id": "ap_seed_014", "create_time": "2025-01-17T10:10:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Invoice INV-777 from Shadow LLC for $3,000 rejected — duplicate invoice number. Same invoice number submitted twice. Shadow LLC flagged for duplicate attempt. High risk. Always verify invoice numbers against history.", "refer_list": []}, + + {"message_id": "ap_seed_015", "create_time": "2025-01-18T09:00:00Z", "sender": "ap_agent_user", "sender_name": "user", "type": "text", "content": "I need to switch to a different task now. Can you pull up the Q1 2025 departmental budget summary?", "refer_list": []}, + {"message_id": "ap_seed_016", "create_time": "2025-01-18T09:00:30Z", "sender": "ap_agent_system", "sender_name": "assistant", "type": "text", "content": "Sure, here is the Q1 2025 departmental budget summary. Total allocated: $2.4M across 8 departments. Engineering has the largest allocation at $680K, followed by Sales at $520K. All departments are currently within budget. Let me know if you need a detailed breakdown.", "refer_list": []} + ] +} diff --git a/demo/ap-memory-agent/evermem_client.py b/demo/ap-memory-agent/evermem_client.py new file mode 100644 index 00000000..2d4beb0b --- /dev/null +++ b/demo/ap-memory-agent/evermem_client.py @@ -0,0 +1,221 @@ +""" +EverMemClient - Thin wrapper around the EverMemOS REST API. + +Core endpoints: + POST /api/v1/memories → Store a message (MemorizeMessageRequest format) + GET /api/v1/memories → Fetch memories by type + GET /api/v1/memories/search → Search memories + DELETE /api/v1/memories → Delete memories + +All requests use Content-Type: application/json. +GET requests use JSON body (server merges with query params). +""" + +import os +import uuid +import requests +from datetime import datetime, timezone +from typing import Any + + +class EverMemClient: + """ + Thin wrapper around the EverMemOS REST API. + + Key parameters: + retrieve_method : keyword | vector | hybrid | rrf | agentic + memory_types : episodic_memory | foresight | event_log (profile NOT on search) + role : user | assistant + """ + + def __init__(self): + self.base_url = os.getenv("EVERMEM_BASE_URL", "http://localhost:1995") + self.user_id = os.getenv("EVERMEM_USER_ID", "ap_agent_system") + self.group_id = os.getenv("EVERMEM_GROUP_ID") # For group-scoped memories (seed data) + self.headers = {"Content-Type": "application/json"} + + def write_memory( + self, + content: str, + role: str = "assistant", + memory_type: str = "episodic_memory", + metadata: dict | None = None, + *, + group_id: str | None = None, + group_name: str | None = None, + sender: str | None = None, + sync_mode: bool = False, + ) -> dict: + """ + Store a memory in EverMemOS. + + EverMemOS expects MemorizeMessageRequest format. memory_type and metadata + are for internal use only — fold context into content. The system extracts + memories from the message content. + + role: "assistant" for agent-generated memories, "user" for user inputs. + sync_mode: If True, add ?sync_mode=false to wait for extraction (slower but ensures memories are indexed). + """ + del memory_type, metadata # Not sent to API; structure content instead + effective_sender = sender or self.user_id + payload = { + "message_id": f"ap_{uuid.uuid4().hex[:12]}", + "create_time": datetime.now(timezone.utc).isoformat(), + "sender": effective_sender, + "content": content, + "role": role, + } + if group_id: + payload["group_id"] = group_id + if group_name: + payload["group_name"] = group_name + + url = f"{self.base_url}/api/v1/memories" + if sync_mode: + url += "?sync_mode=false" + + response = requests.post(url, json=payload, headers=self.headers, timeout=120) + response.raise_for_status() + return response.json() + + def write_vendor_profile(self, vendor: str, profile_content: str) -> dict: + """ + Write or update stable vendor profile. Folds vendor into content + since EverMemOS has no metadata field on MemorizeMessageRequest. + """ + content = f"[Vendor profile for {vendor}] {profile_content}" + return self.write_memory( + content=content, + role="assistant", + memory_type="profile", + ) + + def search_memories( + self, + query: str, + retrieve_method: str = "agentic", + memory_types: list[str] | None = None, + top_k: int = 10, + ) -> list[dict]: + """ + Search vendor memory. memory_types: episodic_memory | foresight | event_log. + Profile is NOT supported on /search — use fetch_memories_by_type for profile. + """ + if memory_types is None: + memory_types = ["episodic_memory", "event_log"] + payload = { + "query": query, + "retrieve_method": retrieve_method, + "memory_types": memory_types, + "top_k": top_k, + } + if self.group_id: + payload["group_id"] = self.group_id + else: + payload["user_id"] = self.user_id + response = requests.get( + f"{self.base_url}/api/v1/memories/search", + json=payload, + headers=self.headers, + ) + response.raise_for_status() + data = response.json() + raw = data.get("result", data) + memories = raw.get("memories", raw.get("results", [])) + return self._flatten_search_results(memories) + + def _flatten_search_results(self, memories: Any) -> list[dict]: + """Flatten nested {group_id: [records]} into a single list.""" + if not memories or not isinstance(memories, list): + return [] + flat: list[dict] = [] + for item in memories: + if isinstance(item, dict): + for group_id, records in item.items(): + if isinstance(records, list): + for r in records: + rec = dict(r) if isinstance(r, dict) else {"content": str(r)} + rec.setdefault("group_id", group_id) + flat.append(rec) + break + else: + flat.append(item) + return flat if flat else list(memories) + + def fetch_memories_by_type( + self, + memory_types: list[str] | None = None, + top_k: int = 20, + ) -> list[dict]: + """ + Fetch memories by type (profile, episodic_memory, etc.). + EverMemOS uses memory_type (singular) and limit per request. + """ + if memory_types is None: + memory_types = ["episodic_memory"] + all_memories: list[dict] = [] + for memory_type in memory_types: + payload = { + "memory_type": memory_type, + "limit": top_k, + "offset": 0, + } + if self.group_id: + payload["group_id"] = self.group_id + else: + payload["user_id"] = self.user_id + response = requests.get( + f"{self.base_url}/api/v1/memories", + json=payload, + headers=self.headers, + ) + response.raise_for_status() + data = response.json() + raw = data.get("result", data) + memories = raw.get("memories", raw.get("results", [])) + if isinstance(memories, list): + for m in memories: + rec = dict(m) if isinstance(m, dict) else {"content": str(m)} + rec.setdefault("memory_type", memory_type) + all_memories.append(rec) + return all_memories + + def format_memory_context(self, memories: list[dict]) -> str: + """Convert raw memory results into a clean string for Claude's context.""" + if not memories: + return "No prior history found for this vendor." + lines = [] + for i, mem in enumerate(memories, 1): + content = ( + mem.get("episode") + or mem.get("content") + or mem.get("memory") + or mem.get("summary") + or mem.get("atomic_fact") + or mem.get("subject") + or str(mem) + ) + if isinstance(content, dict): + content = str(content) + profile_data = mem.get("profile_data") + if profile_data and isinstance(profile_data, dict): + content = str(profile_data) if not content else content + mem_type = mem.get("memory_type", mem.get("type", "memory")) + timestamp = mem.get("created_at", mem.get("timestamp", "unknown date")) + lines.append(f"[{str(mem_type).upper()} {i} | {timestamp}]: {content}") + return "\n".join(lines) + + def delete_memories(self, user_id: str | None = None, group_id: str | None = None) -> dict: + """Delete memories. Uses group_id if set, else user_id.""" + payload: dict[str, str] = {} + if group_id or self.group_id: + payload["group_id"] = group_id or self.group_id or "" + else: + payload["user_id"] = user_id or self.user_id + response = requests.delete( + f"{self.base_url}/api/v1/memories", + json=payload, + headers=self.headers, + ) + response.raise_for_status() + return response.json() diff --git a/demo/ap-memory-agent/requirements.txt b/demo/ap-memory-agent/requirements.txt new file mode 100644 index 00000000..d15f3a52 --- /dev/null +++ b/demo/ap-memory-agent/requirements.txt @@ -0,0 +1,6 @@ +langgraph>=0.2.0 +langchain-anthropic>=0.3.0 +langchain-core>=0.3.0 +streamlit>=1.40.0 +requests>=2.32.0 +python-dotenv>=1.0.0 diff --git a/demo/ap-memory-agent/seed_memory.py b/demo/ap-memory-agent/seed_memory.py new file mode 100644 index 00000000..acc3bf18 --- /dev/null +++ b/demo/ap-memory-agent/seed_memory.py @@ -0,0 +1,434 @@ +""" +Seed mock vendor history into EverMemOS for the AP Memory Agent demo. + +Two modes: + --direct (default): Insert pre-built episodic memories directly into MongoDB + ES. + No LLM credits needed. Fast and reliable. + --via-api: POST messages through the EverMemOS API pipeline (requires LLM). + +Usage: + python seed_memory.py # Direct seed (recommended) + python seed_memory.py --clear # Clear first, then seed + python seed_memory.py --via-api # Seed through API pipeline (needs LLM credits) + +Requires: EVERMEM_GROUP_ID=eb6618c4d52d3bf9_group in .env (matches ap_agent_system) +""" + +import argparse +import asyncio +import json +import os +import re +from pathlib import Path +from datetime import datetime, timezone + +import httpx +from dotenv import load_dotenv + +load_dotenv() + +BASE_URL = os.getenv("EVERMEM_BASE_URL", "http://localhost:1995") +SEED_JSON = Path(__file__).parent / "data" / "ap_agent_seed.json" +GROUP_ID = "eb6618c4d52d3bf9_group" # hash(ap_agent_system)_group +GROUP_NAME = "AP Agent Vendor History" + +# Delay between messages (seconds) - for --via-api mode +MESSAGE_DELAY_SEC = 0.5 + +# Common English stopwords to filter from search_content +_STOPWORDS = { + "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", + "of", "with", "by", "from", "as", "is", "was", "are", "were", "be", + "been", "being", "have", "has", "had", "do", "does", "did", "will", + "would", "shall", "should", "may", "might", "must", "can", "could", + "not", "no", "nor", "so", "if", "then", "than", "that", "this", + "these", "those", "it", "its", "he", "she", "they", "them", "his", + "her", "their", "we", "our", "you", "your", "my", "am", "up", "out", +} + + +def _tokenize_for_search(*texts: str) -> list[str]: + """Tokenize text into search terms (mimics jieba + stopword filtering for English).""" + combined = " ".join(t for t in texts if t) + words = re.findall(r"[a-zA-Z0-9]+(?:[-][a-zA-Z0-9]+)*", combined) + return [w.lower() for w in words if len(w) >= 2 and w.lower() not in _STOPWORDS] + + +# ───────────────────────────────────────────── +# PRE-BUILT EPISODIC MEMORIES (no LLM needed) +# ───────────────────────────────────────────── + +EPISODIC_MEMORIES = [ + { + "subject": "Acme Corp Invoices INV-001 to INV-005 Approved — Clean Payment History January 2025", + "episode": ( + "On January 15, 2025 at 10:00 AM UTC, the user inquired about the status of " + "multiple Acme Corp invoices. Invoice INV-001 for $500 was approved with Net 30 terms " + "and paid on time, covering office supplies. Invoice INV-002 for $450 was approved " + "with Net 30 terms and paid within terms for software licenses. Invoices INV-003 ($550), " + "INV-004 ($480), and INV-005 ($520) were all approved with Net 30 terms and clean " + "payment history. Acme Corp is assessed as low-risk: 5 invoices approved in the past " + "6 months, amounts ranging $400-$600, with no disputes or late payments." + ), + "timestamp": "2025-01-15T10:02:30", + "participants": ["ap_agent_user", "ap_agent_system"], + }, + { + "subject": "Globex Supplies Invoices INV-101 to INV-104 — Dispute and Late Payment History January 2025", + "episode": ( + "On January 16, 2025 at 10:05 AM UTC, the user inquired about Globex Supplies " + "invoices INV-101 and INV-102. The assistant confirmed INV-101 for $1,200 was approved " + "with Net 30 terms and paid on time, and INV-102 for $980 was similarly approved and " + "paid on time. The user then asked about any disputes with Globex Supplies. The " + "assistant reported that Invoice INV-103 for $2,000 was disputed in March 2025 due to " + "shipment delay, resolved by issuing partial credit. The user asked about late payments " + "from Globex Supplies. The assistant stated that INV-104 for $1,500 was paid 15 days " + "late under Net 30 terms. Globex Supplies has a prior dispute and late payment history, " + "assessed as moderate risk." + ), + "timestamp": "2025-01-16T10:07:30", + "participants": ["ap_agent_user", "ap_agent_system"], + }, + { + "subject": "Shadow LLC Duplicate Invoice INV-777 Rejected — High Risk Vendor January 2025", + "episode": ( + "On January 17, 2025 at 10:10 AM UTC, the user inquired about the status of " + "invoice INV-777 from Shadow LLC. The assistant reported that invoice INV-777 for " + "$3,000 was rejected due to a duplicate invoice number — the same invoice number was " + "submitted twice. Shadow LLC was flagged for the duplicate attempt and assessed as " + "high risk. The assistant advised to always verify invoice numbers against history " + "when processing Shadow LLC invoices." + ), + "timestamp": "2025-01-17T10:10:30", + "participants": ["ap_agent_user", "ap_agent_system"], + }, +] + + +def clear_group_data(): + """Clear all MongoDB and ES data for the group.""" + from pymongo import MongoClient as _MongoClient + + print(" Clearing MongoDB...") + _client = _MongoClient( + "mongodb://admin:memsys123@localhost:27017/memsys?authSource=admin" + ) + _db = _client["memsys"] + for coll_name in _db.list_collection_names(): + r = _db[coll_name].delete_many({"group_id": GROUP_ID}) + if r.deleted_count > 0: + print(f" {coll_name}: {r.deleted_count} deleted") + _client.close() + + # Clear ES + try: + import requests + resp = requests.post( + "http://localhost:19200/episodic-memory-memsys-*/_delete_by_query", + json={"query": {"term": {"group_id": GROUP_ID}}}, + headers={"Content-Type": "application/json"}, + timeout=10, + ) + if resp.ok: + deleted = resp.json().get("deleted", 0) + if deleted: + print(f" Elasticsearch: {deleted} deleted") + except Exception: + pass + print(" Cleared.") + + +def seed_direct(clear_first: bool): + """Insert pre-built episodic memories directly into MongoDB + Elasticsearch.""" + from pymongo import MongoClient as _MongoClient + + if clear_first: + clear_group_data() + + # 1. Save conversation meta via API + print(" Saving conversation-meta via API...") + import requests + meta_payload = { + "version": "1.0", + "scene": "assistant", + "scene_desc": {"description": "AP Agent vendor history for demo"}, + "name": GROUP_NAME, + "description": "Vendor invoice history: Acme Corp (clean), Globex Supplies (dispute), Shadow LLC (duplicate).", + "group_id": GROUP_ID, + "created_at": "2025-01-15T00:00:00Z", + "default_timezone": "UTC", + "user_details": { + "ap_agent_user": {"full_name": "AP User", "role": "user", "extra": {}}, + "ap_agent_system": {"full_name": "AP Agent", "role": "assistant", "extra": {}}, + }, + "tags": ["AP", "Vendor", "Invoice"], + } + try: + resp = requests.post( + f"{BASE_URL}/api/v1/memories/conversation-meta", + json=meta_payload, + headers={"Content-Type": "application/json"}, + timeout=10, + ) + if resp.status_code == 200: + print(" Conversation-meta saved.") + else: + print(f" Warning: conversation-meta HTTP {resp.status_code}") + except Exception as e: + print(f" Warning: conversation-meta failed: {e}") + + # 2. Insert episodic memories directly into MongoDB + print(" Inserting episodic memories into MongoDB...") + _client = _MongoClient( + "mongodb://admin:memsys123@localhost:27017/memsys?authSource=admin" + ) + _db = _client["memsys"] + now = datetime.now(timezone.utc) + + inserted = 0 + for mem in EPISODIC_MEMORIES: + ts = datetime.fromisoformat(mem["timestamp"].replace("Z", "+00:00")) + # Insert group episode + personal episodes (for each participant) + for user_id in [None] + mem["participants"]: + doc = { + "created_at": now, + "updated_at": now, + "deleted_at": None, + "deleted_by": None, + "deleted_id": 0, + "user_id": user_id, + "memory_type": "episodic_memory", + "subject": mem["subject"], + "summary": mem["episode"][:200], + "episode": mem["episode"], + "timestamp": ts, + "group_id": GROUP_ID, + "group_name": GROUP_NAME, + "participants": mem["participants"], + "type": "Conversation", + "extend": None, + "memcell_event_id_list": [], + "ori_event_id_list": [], + "keywords": [], + } + _db.episodic_memories.insert_one(doc) + inserted += 1 + _client.close() + print(f" Inserted {inserted} episodic memories ({len(EPISODIC_MEMORIES)} episodes x 3 copies).") + + # 3. Index into Elasticsearch + print(" Indexing into Elasticsearch...") + try: + _client2 = _MongoClient( + "mongodb://admin:memsys123@localhost:27017/memsys?authSource=admin" + ) + _db2 = _client2["memsys"] + es_docs = [] + for doc in _db2.episodic_memories.find({"group_id": GROUP_ID}): + doc_id = str(doc["_id"]) + subject = doc.get("subject", "") + summary = doc.get("summary", "") + episode = doc.get("episode", "") + search_content = _tokenize_for_search(subject, summary, episode) + es_doc = { + "_id": doc_id, + "event_id": doc_id, + "search_content": search_content, + "title": subject, + "subject": subject, + "summary": summary, + "episode": episode, + "group_id": doc.get("group_id", ""), + "group_name": doc.get("group_name", ""), + "user_id": doc.get("user_id"), + "participants": doc.get("participants", []), + "timestamp": doc.get("timestamp").isoformat() if doc.get("timestamp") else None, + "created_at": doc.get("created_at").isoformat() if doc.get("created_at") else None, + "type": doc.get("type", "Conversation"), + } + es_docs.append(es_doc) + + # Find the ES index name + resp = requests.get("http://localhost:19200/_cat/indices?format=json", timeout=5) + es_index = None + if resp.ok: + for idx in resp.json(): + if "episodic-memory-memsys" in idx.get("index", ""): + es_index = idx["index"] + break + + if es_index and es_docs: + # Bulk index + bulk_body = "" + for es_doc in es_docs: + doc_id = es_doc.pop("_id") + bulk_body += json.dumps({"index": {"_index": es_index, "_id": doc_id}}) + "\n" + bulk_body += json.dumps(es_doc) + "\n" + resp = requests.post( + f"http://localhost:19200/_bulk", + data=bulk_body, + headers={"Content-Type": "application/x-ndjson"}, + timeout=10, + ) + if resp.ok: + result = resp.json() + errors = result.get("errors", False) + print(f" ES indexed {len(es_docs)} docs (errors={errors}).") + else: + print(f" ES bulk index failed: HTTP {resp.status_code}") + else: + print(f" Warning: ES index not found or no docs to index.") + _client2.close() + except Exception as e: + print(f" Warning: ES indexing failed: {e}") + + print(f"\nDone. {len(EPISODIC_MEMORIES)} vendor episodes seeded.") + print("Run: uv run python demo/ap-memory-agent/test_demo.py") + + +# ───────────────────────────────────────────── +# ORIGINAL API-BASED SEEDING (requires LLM) +# ───────────────────────────────────────────── + +def load_conversation() -> tuple: + """Load conversation from JSON. Returns (messages, group_id, group_name, meta).""" + if not SEED_JSON.exists(): + raise FileNotFoundError(f"Seed data not found: {SEED_JSON}") + with open(SEED_JSON, encoding="utf-8") as f: + data = json.load(f) + messages = data.get("conversation_list", []) + meta = data.get("conversation_meta", {}) + group_id = meta.get("group_id", GROUP_ID) + group_name = meta.get("name", "AP Agent Vendor History") + user_details = meta.get("user_details", {}) + for msg in messages: + msg["group_id"] = group_id + msg["group_name"] = group_name + sender = msg.get("sender") + if sender and sender in user_details: + msg["role"] = user_details[sender].get("role", "user") + else: + msg["role"] = "assistant" if "system" in str(sender or "").lower() else "user" + return messages, group_id, group_name, meta + + +async def upsert_conversation_meta( + client: httpx.AsyncClient, + meta: dict, + messages: list, + group_id: str, + group_name: str, +) -> None: + """Save conversation-meta (required for extraction scene).""" + created_at = meta.get("created_at") or ( + messages[0].get("create_time") if messages else None + ) or datetime.now(timezone.utc).isoformat() + + user_details = meta.get("user_details") or {} + if not user_details: + for m in messages: + s = m.get("sender") + if s: + user_details[s] = { + "full_name": m.get("sender_name", s), + "role": "user" if m.get("role") == "user" else "assistant", + "extra": {}, + } + + payload = { + "version": meta.get("version", "1.0"), + "scene": "assistant", + "scene_desc": meta.get("scene_desc", {}), + "name": group_name, + "description": meta.get("description", ""), + "group_id": group_id, + "created_at": created_at, + "default_timezone": meta.get("default_timezone", "UTC"), + "user_details": user_details, + "tags": meta.get("tags", []), + } + + resp = await client.post( + f"{BASE_URL}/api/v1/memories/conversation-meta", + json=payload, + headers={"Content-Type": "application/json"}, + ) + if resp.status_code != 200: + print(f" Warning: conversation-meta failed HTTP {resp.status_code}") + else: + print(" Conversation-meta saved.") + + +async def seed_messages( + client: httpx.AsyncClient, + messages: list, + clear_first: bool, +) -> None: + """POST each message through the API pipeline (requires LLM credits).""" + if clear_first: + clear_group_data() + + url = f"{BASE_URL}/api/v1/memories?sync_mode=false" + extracted = 0 + accumulated = 0 + + for idx, msg in enumerate(messages, 1): + try: + resp = await client.post( + url, json=msg, headers={"Content-Type": "application/json"}, timeout=600 + ) + if resp.status_code == 200: + result = resp.json() + status = result.get("result", {}).get("status_info", "") + count = result.get("result", {}).get("count", 0) + if count > 0 or "extracted" in status.lower(): + extracted += 1 + print(f" [{idx}/{len(messages)}] Extracted") + else: + accumulated += 1 + print(f" [{idx}/{len(messages)}] Queued") + elif resp.status_code == 202: + extracted += 1 + print(f" [{idx}/{len(messages)}] Processing (202)") + else: + print(f" [{idx}/{len(messages)}] HTTP {resp.status_code}") + except httpx.ReadTimeout: + print(f" [{idx}/{len(messages)}] Timeout (continuing)") + except Exception as e: + print(f" [{idx}/{len(messages)}] Error: {e}") + + if idx < len(messages): + await asyncio.sleep(MESSAGE_DELAY_SEC) + + print(f"\n Summary: {extracted} extracted, {accumulated} queued") + + +async def main_api(clear: bool) -> None: + messages, group_id, group_name, meta = load_conversation() + print(f"Loaded {len(messages)} messages from {SEED_JSON}") + print(f"Group ID: {group_id}") + + async with httpx.AsyncClient(timeout=600) as client: + await upsert_conversation_meta(client, meta, messages, group_id, group_name) + await seed_messages(client, messages, clear) + + print("\nDone.") + print("Run: uv run python demo/ap-memory-agent/test_demo.py") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Seed vendor history for AP Memory Agent demo") + parser.add_argument("--clear", action="store_true", help="Delete group memories before seeding") + parser.add_argument("--via-api", action="store_true", help="Seed via API pipeline (requires LLM credits)") + args = parser.parse_args() + + if args.via_api: + print("Seeding via API pipeline (requires LLM credits)...") + asyncio.run(main_api(args.clear)) + else: + print("Seeding directly into MongoDB + Elasticsearch (no LLM needed)...") + seed_direct(args.clear) + + +if __name__ == "__main__": + main() diff --git a/demo/ap-memory-agent/streamlit_app.py b/demo/ap-memory-agent/streamlit_app.py new file mode 100644 index 00000000..619deb88 --- /dev/null +++ b/demo/ap-memory-agent/streamlit_app.py @@ -0,0 +1,100 @@ +""" +AP Memory Agent - Streamlit UI + +Run with: streamlit run streamlit_app.py +(From demo/ap-memory-agent/ directory) +""" + +import streamlit as st + +from ap_agent_graph import build_ap_graph, APState + + +def run_streamlit_ui() -> None: + st.set_page_config(page_title="AP Memory Agent", page_icon="🧾", layout="wide") + st.title("🧾 AP Automation Agent") + st.caption( + "Powered by LangGraph + EverMemOS + Claude — " + "Multi-agent invoice processing with shared memory" + ) + + st.subheader("Submit Invoice") + with st.form("invoice_form"): + col1, col2 = st.columns(2) + with col1: + vendor_name = st.text_input("Vendor Name", placeholder="e.g. Acme Corp") + invoice_number = st.text_input( + "Invoice Number", placeholder="e.g. INV-2025-0042" + ) + amount = st.number_input("Amount ($)", min_value=0.0, step=100.0) + with col2: + payment_terms = st.selectbox( + "Payment Terms", + ["net-30", "net-15", "net-60", "due-on-receipt"], + ) + due_date = st.date_input("Due Date") + line_items = st.text_area( + "Line Items (optional)", + placeholder="e.g. Software licenses x5, Support hours x10", + ) + + submitted = st.form_submit_button("🚀 Process Invoice", use_container_width=True) + + if submitted and vendor_name and invoice_number: + invoice = { + "vendor_name": vendor_name, + "invoice_number": invoice_number, + "amount": amount, + "payment_terms": payment_terms, + "due_date": str(due_date), + "line_items": line_items, + } + + with st.spinner("Agents processing invoice..."): + graph = build_ap_graph() + initial_state: APState = { + "invoice": invoice, + "memory_context": "", + "risk_flags": [], + "risk_score": 0, + "decision": "", + "reasoning": "", + "invoice_id": "", + "processed_at": "", + } + final_state = graph.invoke(initial_state) + + st.divider() + st.subheader("Agent Decision") + + decision = final_state["decision"] + decision_colors = {"approve": "🟢", "hold": "🟡", "reject": "🔴"} + emoji = decision_colors.get(decision, "⚪") + st.markdown(f"## {emoji} {decision.upper()}") + st.info(final_state["reasoning"]) + + col1, col2 = st.columns(2) + with col1: + st.metric("Risk Score", f"{final_state['risk_score']}/100") + with col2: + flags = final_state["risk_flags"] + st.metric("Risk Flags", len(flags)) + + if flags: + st.subheader("⚠️ Risk Flags") + for flag in flags: + st.warning(flag) + + with st.expander("🧠 Vendor Memory Used in Decision", expanded=True): + st.caption( + "This is what EverMemOS retrieved from shared agent memory " + "before making the decision:" + ) + st.text(final_state["memory_context"]) + + elif submitted: + st.error("Please fill in at least Vendor Name and Invoice Number.") + + +if __name__ == "__main__": + run_streamlit_ui() diff --git a/demo/ap-memory-agent/test_demo.py b/demo/ap-memory-agent/test_demo.py new file mode 100644 index 00000000..e0699f3e --- /dev/null +++ b/demo/ap-memory-agent/test_demo.py @@ -0,0 +1,113 @@ +""" +Test script for the AP Memory Agent demo. + +Run from EverMemOS root: + uv run python demo/ap-memory-agent/test_demo.py + +Or from demo/ap-memory-agent/: + uv run python test_demo.py + +Prerequisites: + - EverMemOS running at http://localhost:1995 + - seed_memory.py already run (vendor history populated) + - ANTHROPIC_API_KEY in .env +""" + +import sys +from pathlib import Path + +# Ensure demo/ap-memory-agent is on path when run from project root +if str(Path(__file__).parent) not in sys.path: + sys.path.insert(0, str(Path(__file__).parent)) + +from ap_agent_graph import build_ap_graph, APState + + +# Example invoices to test (matches the demo script from README) +TEST_INVOICES = [ + { + "name": "Acme Corp (clean) — expect APPROVE", + "invoice": { + "vendor_name": "Acme Corp", + "invoice_number": "INV-2025-0042", + "amount": 500.0, + "payment_terms": "net-30", + "due_date": "2025-03-30", + "line_items": "Office supplies", + }, + }, + { + "name": "Globex Supplies (prior dispute) — expect HOLD", + "invoice": { + "vendor_name": "Globex Supplies", + "invoice_number": "INV-2025-0105", + "amount": 1200.0, + "payment_terms": "net-30", + "due_date": "2025-04-15", + "line_items": "Industrial equipment", + }, + }, + { + "name": "Shadow LLC (duplicate INV-777) — expect REJECT", + "invoice": { + "vendor_name": "Shadow LLC", + "invoice_number": "INV-777", + "amount": 3000.0, + "payment_terms": "net-30", + "due_date": "2025-04-01", + "line_items": "Consulting services", + }, + }, + { + "name": "Acme Corp (new invoice) — expect APPROVE", + "invoice": { + "vendor_name": "Acme Corp", + "invoice_number": "INV-2025-0099", + "amount": 550.0, + "payment_terms": "net-30", + "due_date": "2025-05-01", + "line_items": "Software licenses", + }, + }, +] + + +def run_test(invoice: dict, name: str) -> dict: + """Run the graph with a single invoice and return the result.""" + graph = build_ap_graph() + initial_state: APState = { + "invoice": invoice, + "memory_context": "", + "risk_flags": [], + "risk_score": 0, + "decision": "", + "reasoning": "", + "invoice_id": "", + "processed_at": "", + } + return graph.invoke(initial_state) + + +def main() -> None: + print("=" * 60) + print("AP Memory Agent — Demo Test Script") + print("=" * 60) + print("\nEnsure EverMemOS is running and seed_memory.py has been run.\n") + + for i, test in enumerate(TEST_INVOICES, 1): + print(f"\n--- Test {i}: {test['name']} ---") + result = run_test(test["invoice"], test["name"]) + print(f" Decision: {result['decision'].upper()}") + print(f" Risk Score: {result['risk_score']}/100") + if result["risk_flags"]: + print(f" Flags: {result['risk_flags']}") + print(f" Reasoning: {result['reasoning'][:80]}...") + print() + + print("=" * 60) + print("Tests complete.") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 5e5a6aaf..2643f255 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,8 @@ dependencies = [ "nltk>=3.9.2", "tiktoken>=0.12.0", "prometheus-client>=0.20.0", + # Demo: AP Memory Agent (Streamlit UI) + "streamlit>=1.40.0", ] [build-system] diff --git a/src/biz_layer/mem_memorize.py b/src/biz_layer/mem_memorize.py index e951a13c..e0237c6d 100644 --- a/src/biz_layer/mem_memorize.py +++ b/src/biz_layer/mem_memorize.py @@ -523,11 +523,18 @@ async def _timed_extract_event_logs(): return result if state.is_assistant_scene: - _, foresight_memories, event_logs = await asyncio.gather( + results = await asyncio.gather( _timed_extract_episodes(), _timed_extract_foresights(), _timed_extract_event_logs(), + return_exceptions=True, ) + for i, result in enumerate(results): + if isinstance(result, Exception): + stage_name = ['episodes', 'foresights', 'event_logs'][i] + logger.warning(f"[mem_memorize] {stage_name} extraction failed (non-fatal): {result}") + foresight_memories = results[1] if not isinstance(results[1], Exception) else [] + event_logs = results[2] if not isinstance(results[2], Exception) else [] else: await _timed_extract_episodes() record_extraction_stage( diff --git a/uv.lock b/uv.lock index 26d94d33..87e94b01 100644 --- a/uv.lock +++ b/uv.lock @@ -95,6 +95,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "altair" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "narwhals" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/c0/184a89bd5feba14ff3c41cfaf1dd8a82c05f5ceedbc92145e17042eb08a4/altair-6.0.0.tar.gz", hash = "sha256:614bf5ecbe2337347b590afb111929aa9c16c9527c4887d96c9bc7f6640756b4", size = 763834, upload-time = "2025-11-12T08:59:11.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/33/ef2f2409450ef6daa61459d5de5c08128e7d3edb773fefd0a324d1310238/altair-6.0.0-py3-none-any.whl", hash = "sha256:09ae95b53d5fe5b16987dccc785a7af8588f2dca50de1e7a156efa8a461515f8", size = 795410, upload-time = "2025-11-12T08:59:09.804Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -298,6 +314,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "blockbuster" version = "1.5.25" @@ -694,6 +719,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, +] + [[package]] name = "google-ai-generativelanguage" version = "0.9.0" @@ -1080,6 +1129,18 @@ version = "0.42.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c6/cb/18eeb235f833b726522d7ebed54f2278ce28ba9438e3135ab0278d9792a2/jieba-0.42.1.tar.gz", hash = "sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2", size = 19214172, upload-time = "2020-01-20T14:27:23.5Z" } +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jiter" version = "0.12.0" @@ -1135,6 +1196,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + [[package]] name = "jsonschema-rs" version = "0.29.1" @@ -1150,6 +1226,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/89/3f89de071920208c0eb64b827a878d2e587f6a3431b58c02f63c3468b76e/jsonschema_rs-0.29.1-cp312-cp312-win_amd64.whl", hash = "sha256:a414c162d687ee19171e2d8aae821f396d2f84a966fd5c5c757bd47df0954452", size = 1871774, upload-time = "2025-02-08T21:24:30.824Z" }, ] +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "langchain" version = "1.1.2" @@ -1400,6 +1488,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.1" @@ -1492,6 +1599,7 @@ dependencies = [ { name = "redis" }, { name = "scikit-learn" }, { name = "sqlmodel" }, + { name = "streamlit" }, { name = "tiktoken" }, { name = "tqdm" }, { name = "tzlocal" }, @@ -1584,6 +1692,7 @@ requires-dist = [ { name = "redis", specifier = ">=5.0.0" }, { name = "scikit-learn", specifier = ">=1.3.0" }, { name = "sqlmodel", specifier = ">=0.0.19" }, + { name = "streamlit", specifier = ">=1.40.0" }, { name = "tiktoken", specifier = ">=0.12.0" }, { name = "tqdm", specifier = ">=4.65.0" }, { name = "tzlocal", specifier = ">=5.3.0" }, @@ -1679,6 +1788,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "narwhals" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/59/81d0f4cad21484083466f278e6b392addd9f4205b48d45b5c8771670ebf8/narwhals-2.17.0.tar.gz", hash = "sha256:ebd5bc95bcfa2f8e89a8ac09e2765a63055162837208e67b42d6eeb6651d5e67", size = 620306, upload-time = "2026-02-23T09:44:34.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/27/20770bd6bf8fbe1e16f848ba21da9df061f38d2e6483952c29d2bb5d1d8b/narwhals-2.17.0-py3-none-any.whl", hash = "sha256:2ac5307b7c2b275a7d66eeda906b8605e3d7a760951e188dcfff86e8ebe083dd", size = 444897, upload-time = "2026-02-23T09:44:32.006Z" }, +] + [[package]] name = "nest-asyncio" version = "1.6.0" @@ -1864,6 +1982,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, +] + [[package]] name = "platformdirs" version = "4.5.0" @@ -2082,6 +2219,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/da/fcc9a9fcd4ca946ff402cff20348e838b051d69f50f5d1f5dca4cd3c5eb8/py_spy-0.4.1-py2.py3-none-win_amd64.whl", hash = "sha256:d92e522bd40e9bf7d87c204033ce5bb5c828fca45fa28d970f58d71128069fdc", size = 1818784, upload-time = "2025-07-31T19:33:23.802Z" }, ] +[[package]] +name = "pyarrow" +version = "23.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" }, + { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" }, + { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" }, + { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" }, +] + [[package]] name = "pyasn1" version = "0.6.1" @@ -2175,6 +2327,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] +[[package]] +name = "pydeck" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/40e14e196864a0f61a92abb14d09b3d3da98f94ccb03b49cf51688140dab/pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605", size = 3832240, upload-time = "2024-05-10T15:36:21.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038", size = 6900403, upload-time = "2024-05-10T15:36:17.36Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -2431,6 +2596,20 @@ hiredis = [ { name = "hiredis" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "regex" version = "2025.11.3" @@ -2493,6 +2672,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, +] + [[package]] name = "rsa" version = "4.9.1" @@ -2572,6 +2774,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -2665,6 +2876,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "streamlit" +version = "1.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altair" }, + { name = "blinker" }, + { name = "cachetools" }, + { name = "click" }, + { name = "gitpython" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pyarrow" }, + { name = "pydeck" }, + { name = "requests" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "tornado" }, + { name = "typing-extensions" }, + { name = "watchdog", marker = "sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/66/d887ee80ea85f035baee607c60af024994e17ae9b921277fca9675e76ecf/streamlit-1.54.0.tar.gz", hash = "sha256:09965e6ae7eb0357091725de1ce2a3f7e4be155c2464c505c40a3da77ab69dd8", size = 8662292, upload-time = "2026-02-04T16:37:54.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/1d/40de1819374b4f0507411a60f4d2de0d620a9b10c817de5925799132b6c9/streamlit-1.54.0-py3-none-any.whl", hash = "sha256:a7b67d6293a9f5f6b4d4c7acdbc4980d7d9f049e78e404125022ecb1712f79fc", size = 9119730, upload-time = "2026-02-04T16:37:52.199Z" }, +] + [[package]] name = "structlog" version = "25.5.0" @@ -2720,6 +2960,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, ] +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" }, + { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" }, + { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -2909,6 +3177,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "watchfiles" version = "1.1.1"