From 9afa24622700ba498659317df832f31d16e1a6df Mon Sep 17 00:00:00 2001 From: wangwenwu Date: Wed, 13 May 2026 14:34:17 +0800 Subject: [PATCH 01/44] feat: add memory ingest pipeline and remember command --- src/heta/cli/__init__.py | 2 + src/heta/cli/remember.py | 36 ++++++++++ src/heta/mem/__init__.py | 1 + src/heta/mem/client.py | 45 +++++++++++++ src/heta/mem/db.py | 86 ++++++++++++++++++++++++ src/heta/mem/l0_store.py | 17 +++++ src/heta/mem/l1_extractor.py | 51 ++++++++++++++ src/heta/mem/l1_store.py | 18 +++++ src/heta/mem/l2_extractor.py | 54 +++++++++++++++ src/heta/mem/l2_store.py | 38 +++++++++++ src/heta/mem/meta_store.py | 30 +++++++++ src/heta/mem/models.py | 65 ++++++++++++++++++ src/heta/mem/paths.py | 19 ++++++ src/heta/mem/pipeline.py | 123 ++++++++++++++++++++++++++++++++++ src/heta/mem/prompts.py | 85 +++++++++++++++++++++++ src/heta/mem/session_store.py | 25 +++++++ 16 files changed, 695 insertions(+) create mode 100644 src/heta/cli/remember.py create mode 100644 src/heta/mem/__init__.py create mode 100644 src/heta/mem/client.py create mode 100644 src/heta/mem/db.py create mode 100644 src/heta/mem/l0_store.py create mode 100644 src/heta/mem/l1_extractor.py create mode 100644 src/heta/mem/l1_store.py create mode 100644 src/heta/mem/l2_extractor.py create mode 100644 src/heta/mem/l2_store.py create mode 100644 src/heta/mem/meta_store.py create mode 100644 src/heta/mem/models.py create mode 100644 src/heta/mem/paths.py create mode 100644 src/heta/mem/pipeline.py create mode 100644 src/heta/mem/prompts.py create mode 100644 src/heta/mem/session_store.py diff --git a/src/heta/cli/__init__.py b/src/heta/cli/__init__.py index 6425447..f5d6cc8 100644 --- a/src/heta/cli/__init__.py +++ b/src/heta/cli/__init__.py @@ -9,6 +9,7 @@ from heta.cli.init import interactive_init from heta.cli.insert import insert_command from heta.cli.query import query_command +from heta.cli.remember import remember_command from heta.cli.status import status_command from heta.cli.vector import app as vector_app @@ -38,5 +39,6 @@ def init_command() -> None: app.command("insert")(insert_command) app.command("query")(query_command) app.command("clean")(clean_command) +app.command("remember")(remember_command) app.command("status")(status_command) app.add_typer(vector_app) diff --git a/src/heta/cli/remember.py b/src/heta/cli/remember.py new file mode 100644 index 0000000..e08c047 --- /dev/null +++ b/src/heta/cli/remember.py @@ -0,0 +1,36 @@ +"""CLI command: heta remember.""" + +from __future__ import annotations + +import typer +from rich.console import Console +from rich.panel import Panel + +from heta.config.io import load_config +from heta.mem.pipeline import remember + +console = Console() + + +def remember_command( + text: str = typer.Argument(..., help="Text to remember."), +) -> None: + """Extract and store memories from a piece of text.""" + config = load_config() + if config is None: + console.print("[red]Heta is not initialised. Run `heta init` first.[/red]") + raise typer.Exit(1) + + with console.status("[cyan]Extracting memories...[/cyan]"): + result = remember(text, config) + + console.print( + Panel( + f"[green]L1 episodes:[/green] {result.l1_count}\n" + f"[green]L2 facts:[/green] {result.l2_count}\n" + f"[dim]session: {result.session_id}[/dim]\n" + f"[dim]elapsed: {result.elapsed_s}s[/dim]", + title="[bold]Memory stored[/bold]", + border_style="green", + ) + ) diff --git a/src/heta/mem/__init__.py b/src/heta/mem/__init__.py new file mode 100644 index 0000000..4a9bf41 --- /dev/null +++ b/src/heta/mem/__init__.py @@ -0,0 +1 @@ +"""Little Heta memory module.""" diff --git a/src/heta/mem/client.py b/src/heta/mem/client.py new file mode 100644 index 0000000..30c2820 --- /dev/null +++ b/src/heta/mem/client.py @@ -0,0 +1,45 @@ +"""LLM client factory for the memory module.""" + +from __future__ import annotations + +from openai import OpenAI + +from heta.config.schema import HetaConfig + +EXTRACTION_MODELS = { + "qwen": "qwen-plus", + "chatgpt": "gpt-4.1-mini", + "gemini": "gemini-2.5-flash", +} + + +def build_client(config: HetaConfig) -> tuple[OpenAI, str]: + provider = config.llm.provider + model = EXTRACTION_MODELS[provider] + if provider == "qwen": + return ( + OpenAI( + api_key=config.llm.api_key, + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", + timeout=60, + ), + model, + ) + if provider == "chatgpt": + return OpenAI(api_key=config.llm.api_key, timeout=60), model + if provider == "gemini": + return ( + OpenAI( + api_key=config.llm.api_key, + base_url="https://generativelanguage.googleapis.com/v1beta/openai/", + timeout=60, + ), + model, + ) + raise ValueError(f"Unsupported LLM provider: {provider}") + + +def extra_body(config: HetaConfig) -> dict | None: + if config.llm.provider == "qwen": + return {"enable_thinking": False} + return None diff --git a/src/heta/mem/db.py b/src/heta/mem/db.py new file mode 100644 index 0000000..0a6f444 --- /dev/null +++ b/src/heta/mem/db.py @@ -0,0 +1,86 @@ +"""SQLite connection factory and schema initialisation.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + + +def get_connection(path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(str(path)) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.row_factory = sqlite3.Row + return conn + + +def init_db(conn: sqlite3.Connection) -> None: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS session ( + session_id TEXT PRIMARY KEY, + started_at INTEGER NOT NULL, + ended_at INTEGER, + consolidated INTEGER NOT NULL DEFAULT 0, + consolidated_at INTEGER + ); + + CREATE TABLE IF NOT EXISTS l0_turn ( + session_id TEXT NOT NULL REFERENCES session(session_id), + turn_index INTEGER NOT NULL, + role TEXT NOT NULL, + modality TEXT NOT NULL DEFAULT 'text', + text_content TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(session_id, turn_index) + ); + + CREATE TABLE IF NOT EXISTS memory_meta ( + memory_id TEXT PRIMARY KEY, + memory_type TEXT NOT NULL, + session_id TEXT REFERENCES session(session_id), + origin TEXT NOT NULL, + kb_uid TEXT, + status TEXT NOT NULL DEFAULT 'active', + deprecated_by TEXT REFERENCES memory_meta(memory_id), + recency_score REAL NOT NULL DEFAULT 1.0, + access_freq INTEGER NOT NULL DEFAULT 0, + user_emphasis REAL NOT NULL DEFAULT 0.0, + importance REAL NOT NULL DEFAULT 0.5, + confidence REAL NOT NULL DEFAULT 0.9, + created_at INTEGER NOT NULL, + last_access_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS l1_episodic ( + memory_id TEXT PRIMARY KEY REFERENCES memory_meta(memory_id) ON DELETE CASCADE, + who TEXT NOT NULL, + what TEXT NOT NULL, + where_loc TEXT, + when_ts INTEGER, + when_text TEXT, + why TEXT, + summary TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_l1_when ON l1_episodic(when_ts); + + CREATE TABLE IF NOT EXISTS l2_semantic ( + memory_id TEXT PRIMARY KEY REFERENCES memory_meta(memory_id) ON DELETE CASCADE, + subject TEXT NOT NULL, + predicate TEXT NOT NULL, + object TEXT NOT NULL, + object_type TEXT NOT NULL DEFAULT 'literal', + t_valid_start INTEGER NOT NULL, + t_valid_end INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_l2_subject_active + ON l2_semantic(subject, predicate) WHERE t_valid_end IS NULL; + + CREATE INDEX IF NOT EXISTS idx_l2_object_active + ON l2_semantic(object, predicate) WHERE t_valid_end IS NULL; + + CREATE INDEX IF NOT EXISTS idx_l2_predicate + ON l2_semantic(predicate); + """) + conn.commit() diff --git a/src/heta/mem/l0_store.py b/src/heta/mem/l0_store.py new file mode 100644 index 0000000..ca2232f --- /dev/null +++ b/src/heta/mem/l0_store.py @@ -0,0 +1,17 @@ +"""Write operations for the l0_turn table.""" + +from __future__ import annotations + +import sqlite3 + +from heta.mem.models import L0Turn + + +def insert_turn(conn: sqlite3.Connection, turn: L0Turn) -> None: + conn.execute( + "INSERT INTO l0_turn (session_id, turn_index, role, modality, text_content, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (turn.session_id, turn.turn_index, turn.role, + turn.modality, turn.text_content, turn.created_at), + ) + conn.commit() diff --git a/src/heta/mem/l1_extractor.py b/src/heta/mem/l1_extractor.py new file mode 100644 index 0000000..5456bdd --- /dev/null +++ b/src/heta/mem/l1_extractor.py @@ -0,0 +1,51 @@ +"""LLM-based episodic memory extraction.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from openai import OpenAI + +from heta.config.schema import HetaConfig +from heta.mem.client import extra_body +from heta.mem.prompts import EPISODE_EXTRACTION_PROMPT + +logger = logging.getLogger(__name__) + + +def extract_episodes( + client: OpenAI, + model: str, + text: str, + config: HetaConfig, +) -> list[dict[str, Any]]: + """Call the LLM and return a list of raw episode dicts.""" + response = client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": EPISODE_EXTRACTION_PROMPT}, + {"role": "user", "content": text}, + ], + temperature=0.2, + **({"extra_body": extra_body(config)} if extra_body(config) else {}), + ) + raw = response.choices[0].message.content or "" + return _parse_episodes(raw) + + +def _parse_episodes(raw: str) -> list[dict[str, Any]]: + text = raw.strip() + if text.startswith("```"): + lines = text.splitlines() + text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]) + try: + data = json.loads(text) + episodes = data.get("episodes", []) + if not isinstance(episodes, list): + return [] + return [e for e in episodes if isinstance(e, dict) and "what" in e] + except (json.JSONDecodeError, AttributeError): + logger.warning("Failed to parse episode extraction response: %s", raw[:200]) + return [] diff --git a/src/heta/mem/l1_store.py b/src/heta/mem/l1_store.py new file mode 100644 index 0000000..0b33c72 --- /dev/null +++ b/src/heta/mem/l1_store.py @@ -0,0 +1,18 @@ +"""Write operations for the l1_episodic table.""" + +from __future__ import annotations + +import sqlite3 + +from heta.mem.models import L1Episodic + + +def insert_episodic(conn: sqlite3.Connection, episode: L1Episodic) -> None: + conn.execute( + """INSERT INTO l1_episodic + (memory_id, who, what, where_loc, when_ts, when_text, why, summary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (episode.memory_id, episode.who, episode.what, episode.where_loc, + episode.when_ts, episode.when_text, episode.why, episode.summary), + ) + conn.commit() diff --git a/src/heta/mem/l2_extractor.py b/src/heta/mem/l2_extractor.py new file mode 100644 index 0000000..32d1b92 --- /dev/null +++ b/src/heta/mem/l2_extractor.py @@ -0,0 +1,54 @@ +"""LLM-based semantic fact extraction.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from openai import OpenAI + +from heta.config.schema import HetaConfig +from heta.mem.client import extra_body +from heta.mem.prompts import FACT_EXTRACTION_PROMPT + +logger = logging.getLogger(__name__) + + +def extract_facts( + client: OpenAI, + model: str, + text: str, + config: HetaConfig, +) -> list[dict[str, Any]]: + """Call the LLM and return a list of raw fact dicts.""" + response = client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": FACT_EXTRACTION_PROMPT}, + {"role": "user", "content": text}, + ], + temperature=0.2, + **({"extra_body": extra_body(config)} if extra_body(config) else {}), + ) + raw = response.choices[0].message.content or "" + return _parse_facts(raw) + + +def _parse_facts(raw: str) -> list[dict[str, Any]]: + text = raw.strip() + if text.startswith("```"): + lines = text.splitlines() + text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]) + try: + data = json.loads(text) + facts = data.get("facts", []) + if not isinstance(facts, list): + return [] + return [ + f for f in facts + if isinstance(f, dict) and all(k in f for k in ("subject", "predicate", "object")) + ] + except (json.JSONDecodeError, AttributeError): + logger.warning("Failed to parse fact extraction response: %s", raw[:200]) + return [] diff --git a/src/heta/mem/l2_store.py b/src/heta/mem/l2_store.py new file mode 100644 index 0000000..9d7f55e --- /dev/null +++ b/src/heta/mem/l2_store.py @@ -0,0 +1,38 @@ +"""Write operations and conflict handling for the l2_semantic table.""" + +from __future__ import annotations + +import sqlite3 + +from heta.mem.models import L2Semantic + + +def insert_fact(conn: sqlite3.Connection, fact: L2Semantic) -> None: + conn.execute( + """INSERT INTO l2_semantic + (memory_id, subject, predicate, object, object_type, t_valid_start, t_valid_end) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (fact.memory_id, fact.subject, fact.predicate, fact.object, + fact.object_type, fact.t_valid_start, fact.t_valid_end), + ) + conn.commit() + + +def find_active_conflicts( + conn: sqlite3.Connection, subject: str, predicate: str +) -> list[str]: + """Return memory_ids of active facts with the same subject+predicate.""" + rows = conn.execute( + "SELECT memory_id FROM l2_semantic " + "WHERE subject = ? AND predicate = ? AND t_valid_end IS NULL", + (subject, predicate), + ).fetchall() + return [row["memory_id"] for row in rows] + + +def expire_fact(conn: sqlite3.Connection, memory_id: str, t_valid_end: int) -> None: + conn.execute( + "UPDATE l2_semantic SET t_valid_end = ? WHERE memory_id = ?", + (t_valid_end, memory_id), + ) + conn.commit() diff --git a/src/heta/mem/meta_store.py b/src/heta/mem/meta_store.py new file mode 100644 index 0000000..5977064 --- /dev/null +++ b/src/heta/mem/meta_store.py @@ -0,0 +1,30 @@ +"""CRUD operations for the memory_meta table.""" + +from __future__ import annotations + +import sqlite3 + +from heta.mem.models import MemoryMeta + + +def insert_meta(conn: sqlite3.Connection, meta: MemoryMeta) -> None: + conn.execute( + """INSERT INTO memory_meta + (memory_id, memory_type, session_id, origin, kb_uid, status, + deprecated_by, recency_score, access_freq, user_emphasis, + importance, confidence, created_at, last_access_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (meta.memory_id, meta.memory_type, meta.session_id, meta.origin, + meta.kb_uid, meta.status, meta.deprecated_by, meta.recency_score, + meta.access_freq, meta.user_emphasis, meta.importance, + meta.confidence, meta.created_at, meta.last_access_at), + ) + conn.commit() + + +def deprecate(conn: sqlite3.Connection, memory_id: str, deprecated_by: str) -> None: + conn.execute( + "UPDATE memory_meta SET status = 'deprecated', deprecated_by = ? WHERE memory_id = ?", + (deprecated_by, memory_id), + ) + conn.commit() diff --git a/src/heta/mem/models.py b/src/heta/mem/models.py new file mode 100644 index 0000000..8525b2f --- /dev/null +++ b/src/heta/mem/models.py @@ -0,0 +1,65 @@ +"""Dataclasses for all memory tables.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class Session: + session_id: str + started_at: int + ended_at: int | None = None + consolidated: int = 0 + consolidated_at: int | None = None + + +@dataclass +class L0Turn: + session_id: str + turn_index: int + role: str # user / assistant / system / tool + modality: str # text / audio / image / mixed + text_content: str + created_at: int + + +@dataclass +class MemoryMeta: + memory_id: str + memory_type: str # L1 / L2 + session_id: str | None + origin: str # extracted / promoted / user_explicit / consolidated + created_at: int + last_access_at: int + kb_uid: str | None = None + status: str = "active" + deprecated_by: str | None = None + recency_score: float = 1.0 + access_freq: int = 0 + user_emphasis: float = 0.0 + importance: float = 0.5 + confidence: float = 0.9 + + +@dataclass +class L1Episodic: + memory_id: str + who: str # JSON array, e.g. '["Alice", "Bob"]' + what: str + where_loc: str | None + when_ts: int | None + when_text: str | None + why: str | None + summary: str # used for vector embedding + + +@dataclass +class L2Semantic: + memory_id: str + subject: str + predicate: str + object: str + object_type: str # literal / entity_ref + t_valid_start: int + t_valid_end: int | None = None diff --git a/src/heta/mem/paths.py b/src/heta/mem/paths.py new file mode 100644 index 0000000..bdefef0 --- /dev/null +++ b/src/heta/mem/paths.py @@ -0,0 +1,19 @@ +"""Filesystem paths for the memory module.""" + +from __future__ import annotations + +from pathlib import Path + + +def mem_dir() -> Path: + return Path.home() / ".heta" / "workspace" / "mem" + + +def db_path() -> Path: + return mem_dir() / "mem.sqlite3" + + +def ensure_mem_dir() -> Path: + path = mem_dir() + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/src/heta/mem/pipeline.py b/src/heta/mem/pipeline.py new file mode 100644 index 0000000..eea5a56 --- /dev/null +++ b/src/heta/mem/pipeline.py @@ -0,0 +1,123 @@ +"""Orchestrator for the heta remember pipeline.""" + +from __future__ import annotations + +import json +import time +import uuid +from dataclasses import dataclass + +from heta.config.schema import HetaConfig +from heta.mem import l0_store, l1_store, l2_store, meta_store, session_store +from heta.mem.client import build_client +from heta.mem.db import get_connection, init_db +from heta.mem.l1_extractor import extract_episodes +from heta.mem.l2_extractor import extract_facts +from heta.mem.models import L0Turn, L1Episodic, L2Semantic, MemoryMeta, Session +from heta.mem.paths import db_path, ensure_mem_dir + + +@dataclass +class RememberResult: + session_id: str + l1_count: int + l2_count: int + elapsed_s: float + + +def remember(text: str, config: HetaConfig) -> RememberResult: + ensure_mem_dir() + conn = get_connection(db_path()) + init_db(conn) + + now = int(time.time()) + session_id = str(uuid.uuid4()) + client, model = build_client(config) + + # --- session + L0 --- + session_store.create_session(conn, Session(session_id=session_id, started_at=now)) + l0_store.insert_turn( + conn, + L0Turn( + session_id=session_id, + turn_index=0, + role="user", + modality="text", + text_content=text, + created_at=now, + ), + ) + + # --- extract --- + t0 = time.time() + raw_episodes = extract_episodes(client, model, text, config) + raw_facts = extract_facts(client, model, text, config) + + # --- persist L1 --- + l1_count = 0 + for ep in raw_episodes: + memory_id = str(uuid.uuid4()) + meta = MemoryMeta( + memory_id=memory_id, + memory_type="L1", + session_id=session_id, + origin="extracted", + created_at=now, + last_access_at=now, + ) + episode = L1Episodic( + memory_id=memory_id, + who=json.dumps(ep.get("who", ["user"]), ensure_ascii=False), + what=ep.get("what", ""), + where_loc=ep.get("where_loc"), + when_ts=None, + when_text=ep.get("when_text"), + why=ep.get("why"), + summary=ep.get("summary", ep.get("what", "")), + ) + meta_store.insert_meta(conn, meta) + l1_store.insert_episodic(conn, episode) + l1_count += 1 + + # --- persist L2 (with conflict resolution) --- + l2_count = 0 + for fact in raw_facts: + memory_id = str(uuid.uuid4()) + subject = fact.get("subject", "") + predicate = fact.get("predicate", "") + + # deprecate any active fact with the same subject+predicate + conflicts = l2_store.find_active_conflicts(conn, subject, predicate) + for old_id in conflicts: + l2_store.expire_fact(conn, old_id, now) + meta_store.deprecate(conn, old_id, memory_id) + + meta = MemoryMeta( + memory_id=memory_id, + memory_type="L2", + session_id=session_id, + origin="extracted", + created_at=now, + last_access_at=now, + ) + fact_record = L2Semantic( + memory_id=memory_id, + subject=subject, + predicate=predicate, + object=fact.get("object", ""), + object_type=fact.get("object_type", "literal"), + t_valid_start=now, + ) + meta_store.insert_meta(conn, meta) + l2_store.insert_fact(conn, fact_record) + l2_count += 1 + + session_store.close_session(conn, session_id, int(time.time())) + conn.close() + + return RememberResult( + session_id=session_id, + l1_count=l1_count, + l2_count=l2_count, + elapsed_s=round(time.time() - t0, 2), + ) diff --git a/src/heta/mem/prompts.py b/src/heta/mem/prompts.py new file mode 100644 index 0000000..f9487c9 --- /dev/null +++ b/src/heta/mem/prompts.py @@ -0,0 +1,85 @@ +"""LLM prompt templates for memory extraction.""" + +from __future__ import annotations + +EPISODE_EXTRACTION_PROMPT = """\ +You are an episodic memory extraction engine for long-term personal memory. + +Task: +Extract significant events and experiences from the input text as discrete episodes. +Return STRICT JSON only. Do not output markdown or extra text. + +Schema: +{"episodes":[{"who":["name"],"what":"event verb or short description","where_loc":"location or null","when_text":"original time expression or null","why":"reason or null","summary":"<=60 words self-contained description"}]} + +Definition of a GOOD episode: +A coherent, bounded real-world event or experience — something that happened, is happening, +or is concretely planned — that a person would remember and recount as a story. + +What TO extract: +- Past events: trips, meetings, purchases, job changes, medical visits, conflicts, milestones +- Ongoing situations: a project in progress, a health issue, a relationship change +- Concrete plans: confirmed future events with enough specificity (who, what, when) +- Significant outcomes: a decision made, a problem solved, a goal reached or failed + +What NOT to extract: +- General opinions or preferences (those belong in facts, not episodes) +- Abstract discussions or hypotheticals without resolution +- Trivial micro-exchanges with no event content +- Duplicate episodes restating the same event + +Quantity discipline: +- A short paragraph should yield 1 to 3 episodes. Do not force-create episodes from thin content. +- If no meaningful event is present, return {"episodes":[]}. + +Format rules: +- `summary` must be self-contained: a reader with no context should understand what happened. +- `who` is a JSON array of names. If the subject is implicit (e.g. "I"), use "user". +- `where_loc` and `why` are optional; use null if not mentioned. +- `when_text` preserves the original time expression ("yesterday", "last Monday", etc.). +- Output language should follow input language. +""" + +FACT_EXTRACTION_PROMPT = """\ +You are a semantic memory extraction engine for long-term personal memory. + +Task: +Extract durable, retrieval-useful facts from the input text as atomic subject-predicate-object triples. +Return STRICT JSON only. Do not output markdown or any extra text. + +Schema: +{"facts":[{"subject":"entity name","predicate":"relationship or attribute","object":"value or entity","object_type":"literal"}]} + +object_type is always "literal" unless the object is a known named entity that should be referenced +separately, in which case use "entity_ref". + +Definition of a GOOD fact: +A stable attribute or relationship that would still be useful to know weeks or months later — +who a person is, what they own, believe, or plan, what happened to them. + +What TO extract: +- Personal attributes: occupation, role, education, location, living situation +- Relationships: family, partners, friends, with context +- Preferences and opinions: hobbies, tastes, values — if explicitly stated +- Life events and outcomes: major decisions made, goals achieved, problems resolved +- Possessions, skills, or resources mentioned as notable +- Health, financial, or situational status changes + +What NOT to extract: +- Questions, requests, or intentions never confirmed as outcomes +- Casual small talk or filler without factual content +- Plans or hypotheticals unless explicitly decided or acted upon +- Trivially obvious facts that add no retrieval value +- Restatements of the same fact (avoid duplicates) + +Quantity discipline: +- A short paragraph should yield 2 to 6 facts. Do not pad with minor details. +- If the text contains no durable facts, return {"facts":[]}. + +Format rules: +- `subject` is a named entity (person, organisation, place). Use "user" if implicit. +- `predicate` uses snake_case (e.g. "works_at", "lives_in", "owns", "prefers"). +- `object` is a concise value or name. +- One atomic statement per fact — no conjunctions linking two independent claims. +- Output language should follow input language. +""" diff --git a/src/heta/mem/session_store.py b/src/heta/mem/session_store.py new file mode 100644 index 0000000..c59d7b8 --- /dev/null +++ b/src/heta/mem/session_store.py @@ -0,0 +1,25 @@ +"""CRUD operations for the session table.""" + +from __future__ import annotations + +import sqlite3 + +from heta.mem.models import Session + + +def create_session(conn: sqlite3.Connection, session: Session) -> None: + conn.execute( + "INSERT INTO session (session_id, started_at, ended_at, consolidated, consolidated_at) " + "VALUES (?, ?, ?, ?, ?)", + (session.session_id, session.started_at, session.ended_at, + session.consolidated, session.consolidated_at), + ) + conn.commit() + + +def close_session(conn: sqlite3.Connection, session_id: str, ended_at: int) -> None: + conn.execute( + "UPDATE session SET ended_at = ? WHERE session_id = ?", + (ended_at, session_id), + ) + conn.commit() From 5ce8636b69c7c8bfa9f2a7bd6ed628fbf6475d09 Mon Sep 17 00:00:00 2001 From: wangwenwu Date: Wed, 13 May 2026 18:19:05 +0800 Subject: [PATCH 02/44] feat: add recall, clean, conflict resolution and QA eval for memory - add heta recall command with L0/L1/L2 multi-layer retrieval and LLM ranker - add clean_memory to wipe all memory tables while preserving schema - redesign L2 conflict detection: embedding similarity + LLM judge, no threshold - fix same-session conflict: exclude current session from conflict candidates - fix variable-precision time: store when_text + when_resolved + when_precision - enforce input-language consistency in all extraction prompts - fix object_type list coercion from LLM output - add tests: test_clean_memory (9 cases), test_memory_ingest (14 cases) - add seed_memories.sh for QA test data seeding and eval_qa.py for evaluation --- src/heta/cli/__init__.py | 2 + src/heta/cli/recall.py | 61 ++++++ src/heta/mem/clean.py | 51 +++++ src/heta/mem/client.py | 56 ++--- src/heta/mem/db.py | 81 ++++++-- src/heta/mem/embedder.py | 21 ++ src/heta/mem/l0_search.py | 36 ++++ src/heta/mem/l0_store.py | 4 + src/heta/mem/l1_extractor.py | 36 +++- src/heta/mem/l1_search.py | 37 ++++ src/heta/mem/l1_store.py | 19 +- src/heta/mem/l2_conflict.py | 83 ++++++++ src/heta/mem/l2_extractor.py | 13 +- src/heta/mem/l2_store.py | 70 +++++-- src/heta/mem/models.py | 12 +- src/heta/mem/pipeline.py | 64 ++++-- src/heta/mem/prompts.py | 84 +++++++- src/heta/mem/recall.py | 134 ++++++++++++ tests/eval_qa.py | 371 +++++++++++++++++++++++++++++++++ tests/memory_qa_test.md | 229 +++++++++++++++++++++ tests/seed_memories.sh | 157 ++++++++++++++ tests/test_clean_memory.py | 215 ++++++++++++++++++++ tests/test_memory_ingest.py | 383 +++++++++++++++++++++++++++++++++++ 23 files changed, 2129 insertions(+), 90 deletions(-) create mode 100644 src/heta/cli/recall.py create mode 100644 src/heta/mem/clean.py create mode 100644 src/heta/mem/embedder.py create mode 100644 src/heta/mem/l0_search.py create mode 100644 src/heta/mem/l1_search.py create mode 100644 src/heta/mem/l2_conflict.py create mode 100644 src/heta/mem/recall.py create mode 100644 tests/eval_qa.py create mode 100644 tests/memory_qa_test.md create mode 100755 tests/seed_memories.sh create mode 100644 tests/test_clean_memory.py create mode 100644 tests/test_memory_ingest.py diff --git a/src/heta/cli/__init__.py b/src/heta/cli/__init__.py index f5d6cc8..3143ea8 100644 --- a/src/heta/cli/__init__.py +++ b/src/heta/cli/__init__.py @@ -9,6 +9,7 @@ from heta.cli.init import interactive_init from heta.cli.insert import insert_command from heta.cli.query import query_command +from heta.cli.recall import recall_command from heta.cli.remember import remember_command from heta.cli.status import status_command from heta.cli.vector import app as vector_app @@ -40,5 +41,6 @@ def init_command() -> None: app.command("query")(query_command) app.command("clean")(clean_command) app.command("remember")(remember_command) +app.command("recall")(recall_command) app.command("status")(status_command) app.add_typer(vector_app) diff --git a/src/heta/cli/recall.py b/src/heta/cli/recall.py new file mode 100644 index 0000000..b07f13a --- /dev/null +++ b/src/heta/cli/recall.py @@ -0,0 +1,61 @@ +"""CLI command: heta recall.""" + +from __future__ import annotations + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + +from heta.config.io import load_config +from heta.mem.recall import recall + +console = Console() + +_LAYER_LABELS = { + "raw": "L0 Raw", + "episode": "L1 Episode", + "atomic_fact": "L2 Atomic Fact", +} + + +def recall_command( + query: str = typer.Argument(..., help="What to recall."), + top_k: int = typer.Option(10, "--top-k", "-k", help="Results per layer."), +) -> None: + """Retrieve and rank memories relevant to a query.""" + config = load_config() + if config is None: + console.print("[red]Heta is not initialised. Run `heta init` first.[/red]") + raise typer.Exit(1) + + with console.status("[cyan]Searching memories...[/cyan]"): + result = recall(query, config, top_k=top_k) + + ranking_str = " > ".join(_LAYER_LABELS.get(r, r) for r in result.ranking) + + lines = Text() + lines.append("Layer ranking: ", style="dim") + lines.append(ranking_str + "\n", style="bold cyan") + lines.append("Reason: ", style="dim") + lines.append(result.reason + "\n\n", style="italic") + lines.append(result.answer, style="white") + + console.print(Panel(lines, title=f'[bold]Recall: "{result.query}"[/bold]', border_style="cyan")) + + console.print() + for layer_ev in result.evidence: + if not layer_ev.items: + continue + label = _LAYER_LABELS.get(layer_ev.layer, layer_ev.layer) + console.print(f"[bold]{label}[/bold]") + for item in layer_ev.items: + score = item.get("score", 0) + if layer_ev.layer == "raw": + text = item["text_content"] + elif layer_ev.layer == "episode": + text = item["summary"] + else: + text = item["fact_text"] + console.print(f" [dim][score={score:.3f}][/dim] {text}") + console.print() diff --git a/src/heta/mem/clean.py b/src/heta/mem/clean.py new file mode 100644 index 0000000..f2e0f11 --- /dev/null +++ b/src/heta/mem/clean.py @@ -0,0 +1,51 @@ +"""Wipe all memory data while preserving the schema.""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass + + +@dataclass +class CleanMemoryResult: + deleted_sessions: int + deleted_l0_turns: int + deleted_l1_episodes: int + deleted_l2_facts: int + deleted_meta: int + + +def clean_memory(conn: sqlite3.Connection) -> CleanMemoryResult: + """Delete every row from all memory tables. Schema is preserved.""" + sessions = _count(conn, "session") + turns = _count(conn, "l0_turn") + episodes = _count(conn, "l1_episodic") + facts = _count(conn, "l2_semantic") + meta = _count(conn, "memory_meta") + + # vec0 and FTS5 virtual tables must be cleared before the main tables + # because they reference the same memory_ids. + conn.execute("DELETE FROM l2_fact_vec") + conn.execute("DELETE FROM l1_episode_vec") + conn.execute("DELETE FROM l0_turn_fts") + + # FK cascade handles l1_episodic / l2_semantic when memory_meta is deleted, + # but delete leaf tables explicitly first to avoid any ordering issues. + conn.execute("DELETE FROM l2_semantic") + conn.execute("DELETE FROM l1_episodic") + conn.execute("DELETE FROM memory_meta") + conn.execute("DELETE FROM l0_turn") + conn.execute("DELETE FROM session") + conn.commit() + + return CleanMemoryResult( + deleted_sessions=sessions, + deleted_l0_turns=turns, + deleted_l1_episodes=episodes, + deleted_l2_facts=facts, + deleted_meta=meta, + ) + + +def _count(conn: sqlite3.Connection, table: str) -> int: + return conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] diff --git a/src/heta/mem/client.py b/src/heta/mem/client.py index 30c2820..891dd5c 100644 --- a/src/heta/mem/client.py +++ b/src/heta/mem/client.py @@ -1,4 +1,4 @@ -"""LLM client factory for the memory module.""" +"""LLM and embedding client factories for the memory module.""" from __future__ import annotations @@ -12,31 +12,39 @@ "gemini": "gemini-2.5-flash", } +EMBEDDING_MODELS = { + "qwen": "text-embedding-v4", + "chatgpt": "text-embedding-3-small", + "gemini": "text-embedding-004", +} + +EMBEDDING_DIM = 1024 + +_BASE_URLS = { + "qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "gemini": "https://generativelanguage.googleapis.com/v1beta/openai/", +} + + +def _make_client(config: HetaConfig, timeout: int) -> OpenAI: + kwargs: dict = {"api_key": config.llm.api_key, "timeout": timeout} + if config.llm.provider in _BASE_URLS: + kwargs["base_url"] = _BASE_URLS[config.llm.provider] + return OpenAI(**kwargs) + def build_client(config: HetaConfig) -> tuple[OpenAI, str]: - provider = config.llm.provider - model = EXTRACTION_MODELS[provider] - if provider == "qwen": - return ( - OpenAI( - api_key=config.llm.api_key, - base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", - timeout=60, - ), - model, - ) - if provider == "chatgpt": - return OpenAI(api_key=config.llm.api_key, timeout=60), model - if provider == "gemini": - return ( - OpenAI( - api_key=config.llm.api_key, - base_url="https://generativelanguage.googleapis.com/v1beta/openai/", - timeout=60, - ), - model, - ) - raise ValueError(f"Unsupported LLM provider: {provider}") + """Return (client, model) for text generation.""" + if config.llm.provider not in EXTRACTION_MODELS: + raise ValueError(f"Unsupported LLM provider: {config.llm.provider}") + return _make_client(config, timeout=60), EXTRACTION_MODELS[config.llm.provider] + + +def build_embedding_client(config: HetaConfig) -> tuple[OpenAI, str]: + """Return (client, model) for embedding generation.""" + if config.llm.provider not in EMBEDDING_MODELS: + raise ValueError(f"Unsupported embedding provider: {config.llm.provider}") + return _make_client(config, timeout=120), EMBEDDING_MODELS[config.llm.provider] def extra_body(config: HetaConfig) -> dict | None: diff --git a/src/heta/mem/db.py b/src/heta/mem/db.py index 0a6f444..e6e8c43 100644 --- a/src/heta/mem/db.py +++ b/src/heta/mem/db.py @@ -5,9 +5,17 @@ import sqlite3 from pathlib import Path +import sqlite_vec -def get_connection(path: Path) -> sqlite3.Connection: +from heta.mem.client import EMBEDDING_DIM + + +def get_connection(path: Path, *, with_vec: bool = False) -> sqlite3.Connection: conn = sqlite3.connect(str(path)) + if with_vec: + conn.enable_load_extension(True) + sqlite_vec.load(conn) + conn.enable_load_extension(False) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") conn.row_factory = sqlite3.Row @@ -52,14 +60,16 @@ def init_db(conn: sqlite3.Connection) -> None: ); CREATE TABLE IF NOT EXISTS l1_episodic ( - memory_id TEXT PRIMARY KEY REFERENCES memory_meta(memory_id) ON DELETE CASCADE, - who TEXT NOT NULL, - what TEXT NOT NULL, - where_loc TEXT, - when_ts INTEGER, - when_text TEXT, - why TEXT, - summary TEXT NOT NULL + memory_id TEXT PRIMARY KEY REFERENCES memory_meta(memory_id) ON DELETE CASCADE, + who TEXT NOT NULL, + what TEXT NOT NULL, + where_loc TEXT, + when_ts INTEGER, + when_text TEXT, + when_resolved TEXT, + when_precision TEXT, + why TEXT, + summary TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_l1_when ON l1_episodic(when_ts); @@ -70,17 +80,54 @@ def init_db(conn: sqlite3.Connection) -> None: predicate TEXT NOT NULL, object TEXT NOT NULL, object_type TEXT NOT NULL DEFAULT 'literal', + fact_text TEXT NOT NULL DEFAULT '', t_valid_start INTEGER NOT NULL, t_valid_end INTEGER ); - CREATE INDEX IF NOT EXISTS idx_l2_subject_active - ON l2_semantic(subject, predicate) WHERE t_valid_end IS NULL; - - CREATE INDEX IF NOT EXISTS idx_l2_object_active - ON l2_semantic(object, predicate) WHERE t_valid_end IS NULL; - - CREATE INDEX IF NOT EXISTS idx_l2_predicate - ON l2_semantic(predicate); + CREATE INDEX IF NOT EXISTS idx_l2_predicate ON l2_semantic(predicate); """) + _migrate(conn) + _ensure_vec_table(conn) conn.commit() + + +def _migrate(conn: sqlite3.Connection) -> None: + """Add columns introduced after initial schema creation.""" + l2_cols = {row[1] for row in conn.execute("PRAGMA table_info(l2_semantic)")} + if "fact_text" not in l2_cols: + conn.execute("ALTER TABLE l2_semantic ADD COLUMN fact_text TEXT NOT NULL DEFAULT ''") + if "when_text" not in l2_cols: + conn.execute("ALTER TABLE l2_semantic ADD COLUMN when_text TEXT") + if "when_resolved" not in l2_cols: + conn.execute("ALTER TABLE l2_semantic ADD COLUMN when_resolved TEXT") + if "when_precision" not in l2_cols: + conn.execute("ALTER TABLE l2_semantic ADD COLUMN when_precision TEXT") + + l1_cols = {row[1] for row in conn.execute("PRAGMA table_info(l1_episodic)")} + if "when_resolved" not in l1_cols: + conn.execute("ALTER TABLE l1_episodic ADD COLUMN when_resolved TEXT") + if "when_precision" not in l1_cols: + conn.execute("ALTER TABLE l1_episodic ADD COLUMN when_precision TEXT") + + +def _ensure_vec_table(conn: sqlite3.Connection) -> None: + conn.execute( + f"""CREATE VIRTUAL TABLE IF NOT EXISTS l2_fact_vec USING vec0( + memory_id TEXT PRIMARY KEY, + embedding FLOAT[{EMBEDDING_DIM}] + )""" + ) + conn.execute( + f"""CREATE VIRTUAL TABLE IF NOT EXISTS l1_episode_vec USING vec0( + memory_id TEXT PRIMARY KEY, + embedding FLOAT[{EMBEDDING_DIM}] + )""" + ) + conn.execute( + """CREATE VIRTUAL TABLE IF NOT EXISTS l0_turn_fts USING fts5( + session_id UNINDEXED, + turn_index UNINDEXED, + text_content + )""" + ) diff --git a/src/heta/mem/embedder.py b/src/heta/mem/embedder.py new file mode 100644 index 0000000..007afea --- /dev/null +++ b/src/heta/mem/embedder.py @@ -0,0 +1,21 @@ +"""Embedding calls for the memory module.""" + +from __future__ import annotations + +from openai import OpenAI + +from heta.mem.client import EMBEDDING_DIM + + +def embed_text(client: OpenAI, model: str, text: str) -> list[float]: + response = client.embeddings.create( + model=model, + input=[text], + dimensions=EMBEDDING_DIM, + ) + return response.data[0].embedding + + +def fact_text(subject: str, predicate: str, object_: str) -> str: + """Convert a triple to a natural language string for embedding.""" + return f"{subject} {predicate.replace('_', ' ')} {object_}" diff --git a/src/heta/mem/l0_search.py b/src/heta/mem/l0_search.py new file mode 100644 index 0000000..8821b6a --- /dev/null +++ b/src/heta/mem/l0_search.py @@ -0,0 +1,36 @@ +"""Full-text search on L0 raw turns.""" + +from __future__ import annotations + +import sqlite3 + + +def search_turns(conn: sqlite3.Connection, query: str, top_k: int = 3) -> list[dict]: + """FTS5 search on raw turn text. Returns matching turns with context.""" + # wrap in quotes for phrase search to avoid FTS5 syntax errors on + # punctuation and special characters + fts_query = '"' + query.replace('"', '""') + '"' + try: + rows = conn.execute( + """ + SELECT session_id, turn_index, text_content, rank + FROM l0_turn_fts + WHERE text_content MATCH ? + ORDER BY rank + LIMIT ? + """, + (fts_query, top_k), + ).fetchall() + except Exception: + rows = [] + + results = [] + for r in rows: + score = 1.0 / (1.0 + abs(float(r["rank"]))) + results.append({ + "session_id": r["session_id"], + "turn_index": r["turn_index"], + "text_content": r["text_content"], + "score": score, + }) + return results diff --git a/src/heta/mem/l0_store.py b/src/heta/mem/l0_store.py index ca2232f..e66b3d0 100644 --- a/src/heta/mem/l0_store.py +++ b/src/heta/mem/l0_store.py @@ -14,4 +14,8 @@ def insert_turn(conn: sqlite3.Connection, turn: L0Turn) -> None: (turn.session_id, turn.turn_index, turn.role, turn.modality, turn.text_content, turn.created_at), ) + conn.execute( + "INSERT INTO l0_turn_fts (session_id, turn_index, text_content) VALUES (?, ?, ?)", + (turn.session_id, turn.turn_index, turn.text_content), + ) conn.commit() diff --git a/src/heta/mem/l1_extractor.py b/src/heta/mem/l1_extractor.py index 5456bdd..75173e3 100644 --- a/src/heta/mem/l1_extractor.py +++ b/src/heta/mem/l1_extractor.py @@ -4,6 +4,7 @@ import json import logging +from datetime import datetime, timezone from typing import Any from openai import OpenAI @@ -20,13 +21,17 @@ def extract_episodes( model: str, text: str, config: HetaConfig, + session_ts: int | None = None, ) -> list[dict[str, Any]]: """Call the LLM and return a list of raw episode dicts.""" + anchor_date = _fmt_date(session_ts) + user_content = f"Anchor date: {anchor_date}\n\nText:\n{text}" + response = client.chat.completions.create( model=model, messages=[ {"role": "system", "content": EPISODE_EXTRACTION_PROMPT}, - {"role": "user", "content": text}, + {"role": "user", "content": user_content}, ], temperature=0.2, **({"extra_body": extra_body(config)} if extra_body(config) else {}), @@ -35,6 +40,35 @@ def extract_episodes( return _parse_episodes(raw) +def resolve_when_ts(when_resolved: str | None) -> int | None: + """Parse a variable-precision date string to unix timestamp of period start. + + Accepts: YYYY-MM-DD, YYYY-Www (ISO week), YYYY-MM, YYYY + """ + if not when_resolved: + return None + s = when_resolved.strip() + for fmt in ("%Y-%m-%d", "%Y-%m", "%Y"): + try: + dt = datetime.strptime(s, fmt) + return int(dt.replace(tzinfo=timezone.utc).timestamp()) + except ValueError: + pass + # ISO week: "2026-W21" + try: + dt = datetime.strptime(s + "-1", "%Y-W%W-%w") + return int(dt.replace(tzinfo=timezone.utc).timestamp()) + except ValueError: + pass + return None + + +def _fmt_date(ts: int | None) -> str: + if ts is None: + return datetime.now().strftime("%Y-%m-%d") + return datetime.fromtimestamp(ts).strftime("%Y-%m-%d") + + def _parse_episodes(raw: str) -> list[dict[str, Any]]: text = raw.strip() if text.startswith("```"): diff --git a/src/heta/mem/l1_search.py b/src/heta/mem/l1_search.py new file mode 100644 index 0000000..c79a5c8 --- /dev/null +++ b/src/heta/mem/l1_search.py @@ -0,0 +1,37 @@ +"""Vector search on L1 episodic memory summaries.""" + +from __future__ import annotations + +import sqlite3 + +import sqlite_vec + + +def search_episodes(conn: sqlite3.Connection, embedding: list[float], top_k: int = 3) -> list[dict]: + """Return active episodes closest to the query embedding.""" + rows = conn.execute( + """ + SELECT e.memory_id, e.who, e.what, e.where_loc, e.when_text, e.why, e.summary, v.distance + FROM l1_episode_vec v + JOIN l1_episodic e ON e.memory_id = v.memory_id + JOIN memory_meta m ON m.memory_id = e.memory_id + WHERE v.embedding MATCH ? AND k = ? + AND m.status = 'active' + ORDER BY v.distance + """, + (sqlite_vec.serialize_float32(embedding), max(1, top_k)), + ).fetchall() + + return [ + { + "memory_id": r["memory_id"], + "who": r["who"], + "what": r["what"], + "where_loc": r["where_loc"], + "when_text": r["when_text"], + "why": r["why"], + "summary": r["summary"], + "score": 1.0 / (1.0 + float(r["distance"])), + } + for r in rows + ] diff --git a/src/heta/mem/l1_store.py b/src/heta/mem/l1_store.py index 0b33c72..168260c 100644 --- a/src/heta/mem/l1_store.py +++ b/src/heta/mem/l1_store.py @@ -10,9 +10,22 @@ def insert_episodic(conn: sqlite3.Connection, episode: L1Episodic) -> None: conn.execute( """INSERT INTO l1_episodic - (memory_id, who, what, where_loc, when_ts, when_text, why, summary) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (memory_id, who, what, where_loc, + when_ts, when_text, when_resolved, when_precision, why, summary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (episode.memory_id, episode.who, episode.what, episode.where_loc, - episode.when_ts, episode.when_text, episode.why, episode.summary), + episode.when_ts, episode.when_text, episode.when_resolved, + episode.when_precision, episode.why, episode.summary), + ) + conn.commit() + + +def insert_episode_embedding( + conn: sqlite3.Connection, memory_id: str, embedding: list[float] +) -> None: + import sqlite_vec + conn.execute( + "INSERT INTO l1_episode_vec (memory_id, embedding) VALUES (?, ?)", + (memory_id, sqlite_vec.serialize_float32(embedding)), ) conn.commit() diff --git a/src/heta/mem/l2_conflict.py b/src/heta/mem/l2_conflict.py new file mode 100644 index 0000000..c7de836 --- /dev/null +++ b/src/heta/mem/l2_conflict.py @@ -0,0 +1,83 @@ +"""Semantic conflict detection for L2 fact memories.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from openai import OpenAI + +from heta.config.schema import HetaConfig +from heta.mem.client import extra_body +from heta.mem.embedder import embed_text +from heta.mem.l2_store import search_similar_facts +from heta.mem.prompts import CONFLICT_JUDGE_PROMPT + +logger = logging.getLogger(__name__) + + +def detect_conflicts( + conn: Any, + new_fact_text: str, + llm_client: OpenAI, + llm_model: str, + emb_client: OpenAI, + emb_model: str, + config: HetaConfig, + top_k: int = 10, + session_id: str | None = None, +) -> list[str]: + """Return memory_ids of existing facts that the new fact contradicts.""" + embedding = embed_text(emb_client, emb_model, new_fact_text) + candidates = search_similar_facts(conn, embedding, top_k=top_k, exclude_session_id=session_id) + + if not candidates: + return [], embedding + + ids_to_deprecate = _judge(llm_client, llm_model, new_fact_text, candidates, config) + return ids_to_deprecate, embedding + + +def _judge( + client: OpenAI, + model: str, + new_fact_text: str, + candidates: list[dict], + config: HetaConfig, +) -> list[str]: + candidate_lines = "\n".join( + f'- id: "{c["memory_id"]}" fact: "{c["fact_text"]}"' + for c in candidates + ) + user_msg = f'New fact: "{new_fact_text}"\n\nExisting facts:\n{candidate_lines}' + + kwargs: dict = { + "model": model, + "messages": [ + {"role": "system", "content": CONFLICT_JUDGE_PROMPT}, + {"role": "user", "content": user_msg}, + ], + "temperature": 0.0, + } + body = extra_body(config) + if body: + kwargs["extra_body"] = body + + response = client.chat.completions.create(**kwargs) + raw = (response.choices[0].message.content or "").strip() + return _parse_judge_response(raw) + + +def _parse_judge_response(raw: str) -> list[str]: + text = raw + if text.startswith("```"): + lines = text.splitlines() + text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]) + try: + data = json.loads(text) + result = data.get("deprecate", []) + return result if isinstance(result, list) else [] + except (json.JSONDecodeError, AttributeError): + logger.warning("Failed to parse conflict judge response: %s", raw[:200]) + return [] diff --git a/src/heta/mem/l2_extractor.py b/src/heta/mem/l2_extractor.py index 32d1b92..2e13268 100644 --- a/src/heta/mem/l2_extractor.py +++ b/src/heta/mem/l2_extractor.py @@ -4,6 +4,7 @@ import json import logging +from datetime import datetime from typing import Any from openai import OpenAI @@ -20,13 +21,17 @@ def extract_facts( model: str, text: str, config: HetaConfig, + session_ts: int | None = None, ) -> list[dict[str, Any]]: """Call the LLM and return a list of raw fact dicts.""" + anchor_date = _fmt_date(session_ts) + user_content = f"Anchor date: {anchor_date}\n\nText:\n{text}" + response = client.chat.completions.create( model=model, messages=[ {"role": "system", "content": FACT_EXTRACTION_PROMPT}, - {"role": "user", "content": text}, + {"role": "user", "content": user_content}, ], temperature=0.2, **({"extra_body": extra_body(config)} if extra_body(config) else {}), @@ -35,6 +40,12 @@ def extract_facts( return _parse_facts(raw) +def _fmt_date(ts: int | None) -> str: + if ts is None: + return datetime.now().strftime("%Y-%m-%d") + return datetime.fromtimestamp(ts).strftime("%Y-%m-%d") + + def _parse_facts(raw: str) -> list[dict[str, Any]]: text = raw.strip() if text.startswith("```"): diff --git a/src/heta/mem/l2_store.py b/src/heta/mem/l2_store.py index 9d7f55e..8237c98 100644 --- a/src/heta/mem/l2_store.py +++ b/src/heta/mem/l2_store.py @@ -1,33 +1,75 @@ -"""Write operations and conflict handling for the l2_semantic table.""" +"""Write operations, vector search, and conflict handling for l2_semantic.""" from __future__ import annotations import sqlite3 +import sqlite_vec + from heta.mem.models import L2Semantic def insert_fact(conn: sqlite3.Connection, fact: L2Semantic) -> None: conn.execute( """INSERT INTO l2_semantic - (memory_id, subject, predicate, object, object_type, t_valid_start, t_valid_end) - VALUES (?, ?, ?, ?, ?, ?, ?)""", + (memory_id, subject, predicate, object, object_type, + fact_text, t_valid_start, t_valid_end, when_text, when_resolved, when_precision) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (fact.memory_id, fact.subject, fact.predicate, fact.object, - fact.object_type, fact.t_valid_start, fact.t_valid_end), + fact.object_type, fact.fact_text, fact.t_valid_start, fact.t_valid_end, + fact.when_text, fact.when_resolved, fact.when_precision), + ) + conn.commit() + + +def insert_fact_embedding( + conn: sqlite3.Connection, memory_id: str, embedding: list[float] +) -> None: + conn.execute( + "INSERT INTO l2_fact_vec (memory_id, embedding) VALUES (?, ?)", + (memory_id, sqlite_vec.serialize_float32(embedding)), ) conn.commit() -def find_active_conflicts( - conn: sqlite3.Connection, subject: str, predicate: str -) -> list[str]: - """Return memory_ids of active facts with the same subject+predicate.""" - rows = conn.execute( - "SELECT memory_id FROM l2_semantic " - "WHERE subject = ? AND predicate = ? AND t_valid_end IS NULL", - (subject, predicate), - ).fetchall() - return [row["memory_id"] for row in rows] +def search_similar_facts( + conn: sqlite3.Connection, + embedding: list[float], + top_k: int = 5, + exclude_session_id: str | None = None, +) -> list[dict]: + """Return active facts closest to the given embedding, excluding the current session.""" + if exclude_session_id: + rows = conn.execute( + """ + SELECT s.memory_id, s.fact_text, v.distance + FROM l2_fact_vec v + JOIN l2_semantic s ON s.memory_id = v.memory_id + JOIN memory_meta m ON m.memory_id = s.memory_id + WHERE v.embedding MATCH ? AND k = ? + AND m.status = 'active' + AND s.t_valid_end IS NULL + AND m.session_id != ? + ORDER BY v.distance + """, + (sqlite_vec.serialize_float32(embedding), max(1, top_k), exclude_session_id), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT s.memory_id, s.fact_text, v.distance + FROM l2_fact_vec v + JOIN l2_semantic s ON s.memory_id = v.memory_id + JOIN memory_meta m ON m.memory_id = s.memory_id + WHERE v.embedding MATCH ? AND k = ? + AND m.status = 'active' + AND s.t_valid_end IS NULL + ORDER BY v.distance + """, + (sqlite_vec.serialize_float32(embedding), max(1, top_k)), + ).fetchall() + return [{"memory_id": r["memory_id"], "fact_text": r["fact_text"], "distance": r["distance"]} + for r in rows] def expire_fact(conn: sqlite3.Connection, memory_id: str, t_valid_end: int) -> None: diff --git a/src/heta/mem/models.py b/src/heta/mem/models.py index 8525b2f..e11a8bd 100644 --- a/src/heta/mem/models.py +++ b/src/heta/mem/models.py @@ -48,8 +48,10 @@ class L1Episodic: who: str # JSON array, e.g. '["Alice", "Bob"]' what: str where_loc: str | None - when_ts: int | None - when_text: str | None + when_ts: int | None # unix timestamp of period start + when_text: str | None # original expression ("昨天", "下个月") + when_resolved: str | None # variable-precision: "2026-05-12" / "2026-06" / "2026" + when_precision: str | None # day / week / month / year why: str | None summary: str # used for vector embedding @@ -60,6 +62,10 @@ class L2Semantic: subject: str predicate: str object: str - object_type: str # literal / entity_ref + object_type: str # literal / entity_ref + fact_text: str # natural language form, used for embedding t_valid_start: int t_valid_end: int | None = None + when_text: str | None = None # original relative expression ("下个月") + when_resolved: str | None = None # variable-precision: "2026-06" / "2026-05-12" + when_precision: str | None = None # day / week / month / year diff --git a/src/heta/mem/pipeline.py b/src/heta/mem/pipeline.py index eea5a56..f9249bc 100644 --- a/src/heta/mem/pipeline.py +++ b/src/heta/mem/pipeline.py @@ -9,9 +9,11 @@ from heta.config.schema import HetaConfig from heta.mem import l0_store, l1_store, l2_store, meta_store, session_store -from heta.mem.client import build_client +from heta.mem.client import build_client, build_embedding_client from heta.mem.db import get_connection, init_db -from heta.mem.l1_extractor import extract_episodes +from heta.mem.embedder import embed_text, fact_text +from heta.mem.l1_extractor import extract_episodes, resolve_when_ts +from heta.mem.l2_conflict import detect_conflicts from heta.mem.l2_extractor import extract_facts from heta.mem.models import L0Turn, L1Episodic, L2Semantic, MemoryMeta, Session from heta.mem.paths import db_path, ensure_mem_dir @@ -27,12 +29,13 @@ class RememberResult: def remember(text: str, config: HetaConfig) -> RememberResult: ensure_mem_dir() - conn = get_connection(db_path()) + conn = get_connection(db_path(), with_vec=True) init_db(conn) now = int(time.time()) session_id = str(uuid.uuid4()) - client, model = build_client(config) + llm_client, llm_model = build_client(config) + emb_client, emb_model = build_embedding_client(config) # --- session + L0 --- session_store.create_session(conn, Session(session_id=session_id, started_at=now)) @@ -50,8 +53,8 @@ def remember(text: str, config: HetaConfig) -> RememberResult: # --- extract --- t0 = time.time() - raw_episodes = extract_episodes(client, model, text, config) - raw_facts = extract_facts(client, model, text, config) + raw_episodes = extract_episodes(llm_client, llm_model, text, config, session_ts=now) + raw_facts = extract_facts(llm_client, llm_model, text, config, session_ts=now) # --- persist L1 --- l1_count = 0 @@ -70,27 +73,40 @@ def remember(text: str, config: HetaConfig) -> RememberResult: who=json.dumps(ep.get("who", ["user"]), ensure_ascii=False), what=ep.get("what", ""), where_loc=ep.get("where_loc"), - when_ts=None, + when_ts=resolve_when_ts(ep.get("when_resolved")), when_text=ep.get("when_text"), + when_resolved=ep.get("when_resolved"), + when_precision=ep.get("when_precision"), why=ep.get("why"), summary=ep.get("summary", ep.get("what", "")), ) meta_store.insert_meta(conn, meta) l1_store.insert_episodic(conn, episode) + l1_emb = embed_text(emb_client, emb_model, episode.summary) + l1_store.insert_episode_embedding(conn, memory_id, l1_emb) l1_count += 1 - # --- persist L2 (with conflict resolution) --- + # --- persist L2 (semantic conflict resolution) --- l2_count = 0 - for fact in raw_facts: + for raw_fact in raw_facts: memory_id = str(uuid.uuid4()) - subject = fact.get("subject", "") - predicate = fact.get("predicate", "") - - # deprecate any active fact with the same subject+predicate - conflicts = l2_store.find_active_conflicts(conn, subject, predicate) - for old_id in conflicts: - l2_store.expire_fact(conn, old_id, now) - meta_store.deprecate(conn, old_id, memory_id) + subject = str(raw_fact.get("subject", "")) + predicate = str(raw_fact.get("predicate", "")) + object_ = str(raw_fact.get("object", "")) + raw_object_type = raw_fact.get("object_type", "literal") + object_type_val = raw_object_type[0] if isinstance(raw_object_type, list) else str(raw_object_type) + ft = fact_text(subject, predicate, object_) + + ids_to_deprecate, embedding = detect_conflicts( + conn=conn, + new_fact_text=ft, + llm_client=llm_client, + llm_model=llm_model, + emb_client=emb_client, + emb_model=emb_model, + config=config, + session_id=session_id, + ) meta = MemoryMeta( memory_id=memory_id, @@ -104,12 +120,22 @@ def remember(text: str, config: HetaConfig) -> RememberResult: memory_id=memory_id, subject=subject, predicate=predicate, - object=fact.get("object", ""), - object_type=fact.get("object_type", "literal"), + object=object_, + object_type=object_type_val, + fact_text=ft, t_valid_start=now, + when_text=raw_fact.get("when_text"), + when_resolved=raw_fact.get("when_resolved"), + when_precision=raw_fact.get("when_precision"), ) + + # insert new meta + fact first so FK reference is valid meta_store.insert_meta(conn, meta) + for old_id in ids_to_deprecate: + l2_store.expire_fact(conn, old_id, now) + meta_store.deprecate(conn, old_id, memory_id) l2_store.insert_fact(conn, fact_record) + l2_store.insert_fact_embedding(conn, memory_id, embedding) l2_count += 1 session_store.close_session(conn, session_id, int(time.time())) diff --git a/src/heta/mem/prompts.py b/src/heta/mem/prompts.py index f9487c9..2f9639e 100644 --- a/src/heta/mem/prompts.py +++ b/src/heta/mem/prompts.py @@ -5,12 +5,18 @@ EPISODE_EXTRACTION_PROMPT = """\ You are an episodic memory extraction engine for long-term personal memory. +LANGUAGE RULE (highest priority): +All text fields you output — what, where_loc, why, summary, and names in who — MUST be +written in the SAME language as the input text. If the input is Chinese, write Chinese. +If the input is English, write English. Never translate or switch languages. + Task: Extract significant events and experiences from the input text as discrete episodes. +The input begins with an "Anchor date" line — use it to resolve all relative time expressions. Return STRICT JSON only. Do not output markdown or extra text. Schema: -{"episodes":[{"who":["name"],"what":"event verb or short description","where_loc":"location or null","when_text":"original time expression or null","why":"reason or null","summary":"<=60 words self-contained description"}]} +{"episodes":[{"who":["name"],"what":"event verb or short description","where_loc":"location or null","when_text":"original relative expression or null (e.g. '昨天','下周')","when_resolved":"variable-precision date or null","when_precision":"day|week|month|year or null","why":"reason or null","summary":"<=60 words self-contained description"}]} Definition of a GOOD episode: A coherent, bounded real-world event or experience — something that happened, is happening, @@ -36,19 +42,76 @@ - `summary` must be self-contained: a reader with no context should understand what happened. - `who` is a JSON array of names. If the subject is implicit (e.g. "I"), use "user". - `where_loc` and `why` are optional; use null if not mentioned. -- `when_text` preserves the original time expression ("yesterday", "last Monday", etc.). -- Output language should follow input language. +- `when_text`: copy the original relative expression verbatim ("昨天", "下个月", "last Monday"). +- `when_resolved` + `when_precision`: resolve using the Anchor date with honest precision: + - Day-level: "2026-05-12", precision="day" (e.g. "昨天", "3天前") + - Week-level: "2026-W21", precision="week" (e.g. "下周", "上周") + - Month-level: "2026-06", precision="month" (e.g. "下个月", "上个月") + - Year-level: "2026", precision="year" (e.g. "明年", "去年") + Do NOT pad to YYYY-MM-01 — use only the precision the expression actually conveys. + If unresolvable, both fields are null. +""" + +RECALL_RANKER_PROMPT = """\ +You are a memory-layer ranker for personal memory question answering. +Compare the retrieved evidence from all memory layers and rank the layers from best to worst for answering the given question. +Then synthesise a concise answer using the best available evidence. +Return STRICT JSON only. Do not output markdown or extra text. + +Schema: +{"ranking": ["best_layer", "second_layer", "third_layer"], "answer": "concise answer in the same language as the question", "reason": "one sentence explaining the ranking"} + +Available memory layers: +- raw (L0): original input text preserved verbatim. Exact wording, full context. +- episode (L1): bounded episodic memories — coherent events, experiences, meetings, plans with participants, time, location, and reason. +- atomic_fact (L2): compact factual memories — stable attributes, relationships, preferences, outcomes. + +Rules: +- Rank the layer that most directly answers the question first. +- If a layer has no relevant evidence, rank it last. +- The answer must be grounded in the evidence; do not invent facts. +- If none of the layers contain relevant evidence, set answer to "No relevant memory found." +""" + +CONFLICT_JUDGE_PROMPT = """\ +You are a memory conflict resolver. Given a new fact and a list of existing facts, +decide which existing facts are directly contradicted by the new fact and should be deprecated. +Return STRICT JSON only. Do not output markdown or extra text. + +Schema: +{"deprecate": ["memory_id_1", "memory_id_2"]} + +Rules: +- Only deprecate facts that are DIRECTLY CONTRADICTED (mutually exclusive with the new fact). +- Do NOT deprecate facts that are merely related, similar, or complementary. +- If nothing is contradicted, return {"deprecate": []}. + +Examples of contradiction: + new: "user lives in Beijing" vs existing: "user lives in Shanghai" → deprecate + new: "user works at Alibaba" vs existing: "user works at ByteDance" → deprecate + +Examples of NO contradiction: + new: "user likes running" vs existing: "user likes swimming" → keep both + new: "user age 26" vs existing: "user had meeting with Bob" → keep both """ FACT_EXTRACTION_PROMPT = """\ You are a semantic memory extraction engine for long-term personal memory. +LANGUAGE RULE (highest priority): +All text fields you output — subject, predicate, object — MUST be written in the SAME language +as the input text. If the input is Chinese, write Chinese. If the input is English, write English. +Never translate or switch languages. + Chinese input example: {{"subject":"用户","predicate":"居住在","object":"北京海淀区"}} + English input example: {{"subject":"user","predicate":"lives_in","object":"Haidian, Beijing"}} + Task: Extract durable, retrieval-useful facts from the input text as atomic subject-predicate-object triples. +The input begins with an "Anchor date" line — use it to resolve all relative time expressions. Return STRICT JSON only. Do not output markdown or any extra text. Schema: -{"facts":[{"subject":"entity name","predicate":"relationship or attribute","object":"value or entity","object_type":"literal"}]} +{{"facts":[{{"subject":"entity name","predicate":"relationship or attribute","object":"value or entity","object_type":"literal","when_text":"original relative time expression or null","when_resolved":"variable-precision date or null","when_precision":"day|week|month|year or null"}}]}} object_type is always "literal" unless the object is a known named entity that should be referenced separately, in which case use "entity_ref". @@ -74,12 +137,17 @@ Quantity discipline: - A short paragraph should yield 2 to 6 facts. Do not pad with minor details. -- If the text contains no durable facts, return {"facts":[]}. +- If the text contains no durable facts, return {{"facts":[]}}. Format rules: -- `subject` is a named entity (person, organisation, place). Use "user" if implicit. -- `predicate` uses snake_case (e.g. "works_at", "lives_in", "owns", "prefers"). +- `subject` is a named entity (person, organisation, place). Use "user" if implicit (in input language). +- `predicate` is a short natural-language phrase in the input language describing the relationship + or attribute (e.g. Chinese: "居住在", "就职于", "喜欢", "月薪"; English: "lives_in", "works_at"). - `object` is a concise value or name. - One atomic statement per fact — no conjunctions linking two independent claims. -- Output language should follow input language. +- `when_text`: copy the original relative time expression verbatim if the fact has a temporal + reference ("下个月", "next week"). Null otherwise. +- `when_resolved` + `when_precision`: resolve with honest precision (same rules as episode extraction): + - "2026-05-12" / "day", "2026-W21" / "week", "2026-06" / "month", "2026" / "year" + Do NOT pad month/week expressions to day level. Null if no temporal reference. """ diff --git a/src/heta/mem/recall.py b/src/heta/mem/recall.py new file mode 100644 index 0000000..56978c8 --- /dev/null +++ b/src/heta/mem/recall.py @@ -0,0 +1,134 @@ +"""Orchestrator for the heta recall pipeline.""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field + +from heta.config.schema import HetaConfig +from heta.mem.client import build_client, build_embedding_client, extra_body +from heta.mem.db import get_connection, init_db +from heta.mem.embedder import embed_text +from heta.mem.l0_search import search_turns +from heta.mem.l1_search import search_episodes +from heta.mem.l2_store import search_similar_facts +from heta.mem.paths import db_path, ensure_mem_dir +from heta.mem.prompts import RECALL_RANKER_PROMPT + +logger = logging.getLogger(__name__) + + +@dataclass +class LayerEvidence: + layer: str # raw / episode / atomic_fact + items: list[dict] = field(default_factory=list) + + +@dataclass +class RecallResult: + query: str + ranking: list[str] + answer: str + reason: str + evidence: list[LayerEvidence] + + +def recall(query: str, config: HetaConfig, top_k: int = 10) -> RecallResult: + ensure_mem_dir() + conn = get_connection(db_path(), with_vec=True) + init_db(conn) + + llm_client, llm_model = build_client(config) + emb_client, emb_model = build_embedding_client(config) + + query_embedding = embed_text(emb_client, emb_model, query) + + l0_hits = search_turns(conn, query, top_k=top_k) + l1_hits = search_episodes(conn, query_embedding, top_k=top_k) + l2_hits = search_similar_facts(conn, query_embedding, top_k=top_k) + + conn.close() + + evidence = [ + LayerEvidence(layer="raw", items=l0_hits), + LayerEvidence(layer="episode", items=l1_hits), + LayerEvidence(layer="atomic_fact", items=l2_hits), + ] + + ranking, answer, reason = _rank( + query=query, + evidence=evidence, + client=llm_client, + model=llm_model, + config=config, + ) + + return RecallResult( + query=query, + ranking=ranking, + answer=answer, + reason=reason, + evidence=evidence, + ) + + +def _rank( + query: str, + evidence: list[LayerEvidence], + client, + model: str, + config: HetaConfig, +) -> tuple[list[str], str, str]: + evidence_text = _format_evidence(evidence) + user_msg = f"Question:\n{query}\n\nRetrieved evidence from each memory layer:\n{evidence_text}" + + kwargs: dict = { + "model": model, + "messages": [ + {"role": "system", "content": RECALL_RANKER_PROMPT}, + {"role": "user", "content": user_msg}, + ], + "temperature": 0.2, + } + body = extra_body(config) + if body: + kwargs["extra_body"] = body + + response = client.chat.completions.create(**kwargs) + raw = (response.choices[0].message.content or "").strip() + return _parse_rank_response(raw) + + +def _format_evidence(evidence: list[LayerEvidence]) -> str: + parts = [] + for layer_ev in evidence: + parts.append(f"## {layer_ev.layer}") + if not layer_ev.items: + parts.append("(no results)") + else: + for i, item in enumerate(layer_ev.items, 1): + score = item.get("score", 0) + if layer_ev.layer == "raw": + parts.append(f"[{i}; score={score:.4f}] {item['text_content']}") + elif layer_ev.layer == "episode": + parts.append(f"[{i}; score={score:.4f}] {item['summary']}") + else: + parts.append(f"[{i}; score={score:.4f}] {item['fact_text']}") + return "\n".join(parts) + + +def _parse_rank_response(raw: str) -> tuple[list[str], str, str]: + text = raw + if text.startswith("```"): + lines = text.splitlines() + text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]) + try: + data = json.loads(text) + ranking = data.get("ranking", []) + answer = data.get("answer", "") + reason = data.get("reason", "") + return ranking, answer, reason + except (json.JSONDecodeError, AttributeError): + logger.warning("Failed to parse ranker response: %s", raw[:200]) + return [], raw, "" diff --git a/tests/eval_qa.py b/tests/eval_qa.py new file mode 100644 index 0000000..67fd201 --- /dev/null +++ b/tests/eval_qa.py @@ -0,0 +1,371 @@ +""" +QA evaluation script for heta memory system. + +Usage: + python tests/eval_qa.py # run all questions + python tests/eval_qa.py --out results.json # also save raw JSON + python tests/eval_qa.py -q 1 3 5 # run specific question numbers +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from dataclasses import asdict, dataclass +from pathlib import Path + +# ── allow running from repo root without installing ────────────────────────── +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from heta.config.io import load_config +from heta.mem.client import build_client, extra_body +from heta.mem.recall import recall + +# ── QA definitions ─────────────────────────────────────────────────────────── + +QA_CASES: list[dict] = [ + # ── L2 基础事实(冲突消解)────────────────────────────────────────────── + { + "id": 1, + "category": "L2-冲突消解", + "question": "陈浩现在在哪家公司工作?", + "expected": "星图数据(极光科技已被覆盖)", + "keywords": ["星图数据"], + "anti_keywords": ["极光科技"], + }, + { + "id": 2, + "category": "L2-冲突消解", + "question": "陈浩现在住在哪里?", + "expected": "望京(经历过:朝阳区→海淀区→望京)", + "keywords": ["望京"], + "anti_keywords": ["朝阳", "海淀"], + }, + { + "id": 3, + "category": "L2-冲突消解", + "question": "陈浩现在的薪资是多少?", + "expected": "28k(薪资链:18k→22k→25k→28k)", + "keywords": ["28"], + "anti_keywords": ["18k", "22k", "25k"], + }, + { + "id": 4, + "category": "L2-冲突消解", + "question": "陈浩现在的通勤时间是多少?", + "expected": "步行10分钟(经历过:1小时→20分钟→步行10分钟)", + "keywords": ["10分钟", "步行"], + "anti_keywords": ["1小时", "20分钟"], + }, + { + "id": 5, + "category": "L2-累加事实", + "question": "陈浩喜欢什么运动?", + "expected": "爬山、羽毛球、偶尔篮球、游泳(兴趣爱好应累加保留)", + "keywords": ["爬山", "羽毛球"], + "anti_keywords": [], + }, + { + "id": 6, + "category": "L2-累加事实", + "question": "陈浩在学什么新技术?", + "expected": "Rust(用于高性能数据处理);需要加强Go经验", + "keywords": ["Rust"], + "anti_keywords": [], + }, + { + "id": 7, + "category": "L2-基础属性", + "question": "陈浩的绩效评级是什么?", + "expected": "A绩效(上个季度,在极光科技)", + "keywords": ["A"], + "anti_keywords": [], + }, + # ── L1 情景事件 ──────────────────────────────────────────────────────── + { + "id": 8, + "category": "L1-情景事件", + "question": "陈浩参加了什么技术会议?会议的主题是什么?", + "expected": "公司技术分享会,主题是大模型在工程中的落地,约50人参加", + "keywords": ["大模型", "技术分享", "50"], + "anti_keywords": [], + }, + { + "id": 9, + "category": "L1-情景事件", + "question": "陈浩和李薇开会讨论了什么?", + "expected": "用户画像模块改版需求评审,双方有争议,定三周后上线", + "keywords": ["用户画像", "李薇"], + "anti_keywords": [], + }, + { + "id": 10, + "category": "L1-情景事件", + "question": "陈浩在极光科技最后一天发生了什么?", + "expected": "完成了所有交接,和团队一起吃了散伙饭,感觉不舍", + "keywords": ["散伙饭", "交接"], + "anti_keywords": [], + }, + { + "id": 11, + "category": "L1-情景事件", + "question": "陈浩最近去哪里旅游了?和谁一起去的?大概花了多少钱?", + "expected": "青岛,和王强、赵敏、刘洋,三天,每人约1500元,住市南区民宿", + "keywords": ["青岛", "王强", "1500"], + "anti_keywords": [], + }, + { + "id": 12, + "category": "L1-事件序列", + "question": "陈浩妈妈的健康情况如何?", + "expected": "最初血压高,去协和医院检查,服降压药;后来复查血压稳定,已停药", + "keywords": ["血压", "协和"], + "anti_keywords": [], + }, + # ── 时间推理 ─────────────────────────────────────────────────────────── + { + "id": 13, + "category": "时间推理", + "question": "用户画像模块最终是什么时候上线的?经历了哪些波折?", + "expected": "比原计划晚了近两个月;需求评审定三周后→推迟到下下个月(前端资源不足)→最终上线,首日UV2万", + "keywords": ["推迟", "上线"], + "anti_keywords": [], + }, + { + "id": 14, + "category": "时间推理", + "question": "陈浩什么时候离开极光科技的?", + "expected": "拿到offer后下周一入职星图数据;在极光科技的最后一天完成交接", + "keywords": ["极光科技", "最后"], + "anti_keywords": [], + }, + { + "id": 15, + "category": "时间推理", + "question": "陈浩妈妈的降压药大概吃了多久?", + "expected": "大约一个月(医生开了一个月的药,复查后停药)", + "keywords": ["一个月", "停药"], + "anti_keywords": [], + }, + # ── 复合推理 ─────────────────────────────────────────────────────────── + { + "id": 16, + "category": "复合推理", + "question": "陈浩的职业发展轨迹是什么?", + "expected": "极光科技后端工程师(18k,绩效A)→裁员担忧→加入星图数据(25k→28k,Go/Rust技术栈)", + "keywords": ["极光科技", "星图数据"], + "anti_keywords": [], + }, + { + "id": 17, + "category": "复合推理", + "question": "陈浩换工作的原因是什么?", + "expected": "公司宣布裁员10%有担忧;拿到星图数据更高薪资的offer(25k)", + "keywords": ["裁员", "星图数据"], + "anti_keywords": [], + }, + { + "id": 18, + "category": "复合推理", + "question": "陈浩目前的生活状态怎么样?", + "expected": "住望京,在星图数据工作(28k),学Rust和Go,游泳,妈妈健康稳定", + "keywords": ["望京", "星图数据", "28"], + "anti_keywords": [], + }, + { + "id": 19, + "category": "复合推理", + "question": "用户画像模块这个项目经历了哪些波折?", + "expected": "需求评审有争议→定三周后上线→推迟到下下个月(前端资源)→上线首日UV2万", + "keywords": ["推迟", "前端"], + "anti_keywords": [], + }, + # ── 边界 / 负样本 ────────────────────────────────────────────────────── + { + "id": 20, + "category": "边界-无记忆", + "question": "陈浩有没有去过上海?", + "expected": "没有相关记忆", + "keywords": [], + "anti_keywords": ["上海"], + "expect_no_memory": True, + }, + { + "id": 21, + "category": "边界-无记忆", + "question": "陈浩结婚了吗?", + "expected": "没有相关记忆", + "keywords": [], + "anti_keywords": [], + "expect_no_memory": True, + }, + { + "id": 22, + "category": "边界-基础属性", + "question": "陈浩今年多少岁?", + "expected": "28岁", + "keywords": ["28"], + "anti_keywords": [], + }, + { + "id": 23, + "category": "边界-历史状态", + "question": "陈浩之前说要涨薪到22k,这个涨薪最终兑现了吗?", + "expected": "没有明确记录兑现;后来换工作了,该涨薪应已被覆盖", + "keywords": ["22k", "换工作"], + "anti_keywords": [], + }, + { + "id": 24, + "category": "边界-细节检索", + "question": "陈浩带妈妈去哪家医院看的病?", + "expected": "协和医院", + "keywords": ["协和"], + "anti_keywords": [], + }, + { + "id": 25, + "category": "边界-细节检索", + "question": "陈浩在极光科技认识了哪些人?", + "expected": "产品经理李薇;技术分享会上认识了做推理优化的同事(无具体名字)", + "keywords": ["李薇"], + "anti_keywords": [], + }, +] + + +# ── result dataclass ────────────────────────────────────────────────────────── + +@dataclass +class QAResult: + id: int + category: str + question: str + expected: str + actual_answer: str + layer_ranking: list[str] + keyword_hit: bool + anti_keyword_hit: bool + auto_pass: bool # keyword-based heuristic + elapsed_s: float + error: str = "" + + +# ── scoring ─────────────────────────────────────────────────────────────────── + +def _check_keywords(answer: str, keywords: list[str], anti_keywords: list[str]) -> tuple[bool, bool]: + a = answer.lower() + hit = all(kw.lower() in a for kw in keywords) if keywords else True + anti_hit = any(kw.lower() in a for kw in anti_keywords) if anti_keywords else False + return hit, anti_hit + + +def _auto_pass(case: dict, answer: str) -> bool: + """Heuristic pass: all keywords present AND no anti-keywords.""" + if case.get("expect_no_memory"): + no_mem_phrases = ["no relevant memory", "没有相关记忆", "没有记录", "未找到", "无相关"] + return any(p in answer.lower() for p in no_mem_phrases) + hit, anti = _check_keywords(answer, case["keywords"], case["anti_keywords"]) + return hit and not anti + + +# ── main ────────────────────────────────────────────────────────────────────── + +def run_eval(question_ids: list[int] | None = None) -> list[QAResult]: + config = load_config() + if config is None: + print("[ERROR] Heta is not initialised. Run `heta init` first.", file=sys.stderr) + sys.exit(1) + + cases = QA_CASES if not question_ids else [c for c in QA_CASES if c["id"] in question_ids] + + results: list[QAResult] = [] + for case in cases: + print(f" Q{case['id']:02d} [{case['category']}] {case['question']}", end=" ... ", flush=True) + t0 = time.time() + error = "" + answer = "" + ranking: list[str] = [] + try: + result = recall(case["question"], config) + answer = result.answer + ranking = result.ranking + except Exception as exc: + error = str(exc) + answer = "" + elapsed = round(time.time() - t0, 2) + + hit, anti = _check_keywords(answer, case["keywords"], case["anti_keywords"]) + passed = _auto_pass(case, answer) + + status = "PASS" if passed else "FAIL" + print(f"{status} ({elapsed}s)") + + results.append(QAResult( + id=case["id"], + category=case["category"], + question=case["question"], + expected=case["expected"], + actual_answer=answer, + layer_ranking=ranking, + keyword_hit=hit, + anti_keyword_hit=anti, + auto_pass=passed, + elapsed_s=elapsed, + error=error, + )) + return results + + +def print_report(results: list[QAResult]) -> None: + passed = sum(1 for r in results if r.auto_pass) + total = len(results) + print() + print("=" * 70) + print(f" RESULT: {passed}/{total} passed ({100*passed//total}%)") + print("=" * 70) + + # group by category + by_cat: dict[str, list[QAResult]] = {} + for r in results: + by_cat.setdefault(r.category, []).append(r) + + for cat, group in by_cat.items(): + cat_pass = sum(1 for r in group if r.auto_pass) + print(f"\n── {cat} ({cat_pass}/{len(group)}) ──") + for r in group: + icon = "✓" if r.auto_pass else "✗" + print(f" {icon} Q{r.id:02d}: {r.question}") + if not r.auto_pass: + print(f" 期望: {r.expected}") + print(f" 实际: {r.actual_answer[:200]}") + if r.error: + print(f" 错误: {r.error}") + + avg_t = sum(r.elapsed_s for r in results) / len(results) if results else 0 + print(f"\n平均响应时间: {avg_t:.1f}s") + + +def save_results(results: list[QAResult], path: str) -> None: + data = [asdict(r) for r in results] + Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"结果已保存至 {path}") + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Evaluate heta memory QA") + parser.add_argument("-q", "--questions", nargs="*", type=int, metavar="N", + help="Only run these question IDs (e.g. -q 1 3 5)") + parser.add_argument("--out", metavar="FILE", + help="Save raw JSON results to this file") + args = parser.parse_args() + + print(f"Running {len(QA_CASES) if not args.questions else len(args.questions)} QA cases...\n") + results = run_eval(args.questions) + print_report(results) + if args.out: + save_results(results, args.out) diff --git a/tests/memory_qa_test.md b/tests/memory_qa_test.md new file mode 100644 index 0000000..212466c --- /dev/null +++ b/tests/memory_qa_test.md @@ -0,0 +1,229 @@ +# Memory QA Test + +## 测试记忆(按输入顺序,即时间顺序) + +每条记忆单独运行一次 `heta remember`,顺序不可乱。 + +--- + +### 第一阶段:基础信息建立 + +```bash +heta remember "我叫陈浩,今年28岁,住在北京朝阳区,在一家叫极光科技的初创公司做后端工程师,入职快两年了" +``` + +```bash +heta remember "我平时喜欢爬山,周末经常去香山或者云蒙山,还喜欢打羽毛球,偶尔打篮球,最近开始学习游泳" +``` + +```bash +heta remember "上周去参加了公司的技术分享会,主题是大模型在工程中的落地,大概50人参加,认识了几个做推理优化的同事,收获挺大的" +``` + +```bash +heta remember "昨天和产品经理李薇开了需求评审会,讨论用户画像模块的改版方案,双方争议挺大,最后定下来计划三周后上线" +``` + +```bash +heta remember "公司上个季度给我打了A绩效,HR昨天通知我下个月涨薪,从18k涨到22k" +``` + +--- + +### 第二阶段:状态变更(制造冲突) + +```bash +heta remember "我搬家了,从朝阳区搬到了海淀区,现在离公司更近,通勤时间从原来的1小时缩短到大概20分钟" +``` + +```bash +heta remember "今天收到消息,用户画像模块上线时间推迟了,改到下下个月,原因是前端人手不够,李薇也很无奈" +``` + +```bash +heta remember "公司全员会议宣布下个季度要裁员10%,部门主管说后端组暂时安全,但我还是有点担心" +``` + +```bash +heta remember "我决定换工作了,已经拿到星图数据的offer,职位还是后端工程师,薪资直接给到25k,下周一入职" +``` + +```bash +heta remember "今天是我在极光科技的最后一天,完成了所有交接,和团队一起吃了散伙饭,挺不舍的" +``` + +--- + +### 第三阶段:新状态建立 + 更多事件 + +```bash +heta remember "在星图数据入职了,新公司在北京望京,团队规模比极光科技大很多,主要做企业数据分析产品" +``` + +```bash +heta remember "上个月和大学同学王强、赵敏、刘洋一起去青岛玩了三天,吃了很多海鲜,住在市南区的民宿,费用AA制大概每人花了1500块" +``` + +```bash +heta remember "最近开始学习Rust,打算用来做高性能的数据处理模块,买了《Rust程序设计语言》这本书" +``` + +```bash +heta remember "妈妈最近血压有点高,上周带她去协和医院检查了,医生说要控制饮食、减少盐分,开了一个月的降压药" +``` + +```bash +heta remember "星图数据这边技术栈比极光科技更新,主要用Go和Rust,Python只用来做数据脚本,我需要加强Go的经验" +``` + +--- + +### 第四阶段:进一步冲突与更新 + +```bash +heta remember "我开始学游泳了,报了公司附近游泳馆的课程,教练说我基础不错,一个月后应该能游50米了" +``` + +```bash +heta remember "搬家了,从海淀区搬到了望京,就在公司附近,步行10分钟就到,房租贵了一些但省去了通勤时间" +``` + +```bash +heta remember "星图数据给我调薪了,从25k涨到28k,理由是试用期表现优秀,正式转正同时调薪" +``` + +```bash +heta remember "用户画像模块终于上线了,比最初计划晚了将近两个月,上线后首日UV达到2万,李薇发微信说很感谢我之前的配合" +``` + +```bash +heta remember "妈妈复查了,血压已经控制稳定,医生说可以停药了,饮食方面继续保持就行" +``` + +--- + +## QA Test Cases + +--- + +### L2 基础事实查询(冲突消解验证) + +**Q1**: 陈浩现在在哪家公司工作? +- 期望:星图数据 +- 冲突链:极光科技 → 星图数据,应只保留最新 +- 考察:L2 works_at 冲突消解 + +**Q2**: 陈浩现在住在哪里? +- 期望:望京(北京) +- 冲突链:朝阳区 → 海淀区 → 望京,应只保留最新 +- 考察:多次地址更新后的最终状态 + +**Q3**: 陈浩现在的薪资是多少? +- 期望:28k +- 冲突链:18k → 22k(未兑现,被换工作覆盖)→ 25k → 28k +- 考察:多轮薪资更新,只保留最新 active fact + +**Q4**: 陈浩的通勤时间是多少? +- 期望:步行10分钟(搬到望京后) +- 冲突链:1小时 → 20分钟 → 步行10分钟 +- 考察:通勤时长的多次更新 + +**Q5**: 陈浩喜欢什么运动? +- 期望:爬山、羽毛球、偶尔篮球、游泳(最新加入) +- 考察:兴趣爱好是累加而非互斥,不应有冲突消解 + +**Q6**: 陈浩在学什么新技术? +- 期望:Rust(用于高性能数据处理);在星图数据需要加强 Go +- 考察:技能/学习类 fact 的检索 + +**Q7**: 陈浩的绩效如何? +- 期望:极光科技上个季度绩效 A +- 考察:历史绩效 fact 检索(无冲突,只有一条) + +--- + +### L1 情景事件查询 + +**Q8**: 陈浩参加了什么技术会议? +- 期望:公司技术分享会,主题大模型工程落地,约50人,认识了做推理优化的同事 +- 考察:L1 情景记忆中的事件细节检索 + +**Q9**: 陈浩和李薇开会讨论了什么? +- 期望:用户画像模块改版需求评审,双方有争议,最终定下三周后上线 +- 考察:L1 参与者 + 事件内容联合检索 + +**Q10**: 陈浩在极光科技最后一天发生了什么? +- 期望:完成交接,吃了散伙饭,感觉不舍 +- 考察:L1 情景记忆的情感和细节 + +**Q11**: 陈浩最近去哪里旅游了?和谁一起?花了多少钱? +- 期望:青岛,和王强、赵敏、刘洋,三天,每人约1500元,住市南区民宿 +- 考察:L1 多字段联合检索(who、where、when、cost) + +**Q12**: 陈浩妈妈的健康情况怎么样? +- 期望:最初血压高,去协和检查,服药控制;后来复查已稳定,停药了 +- 考察:L1 事件序列检索,包含状态变化 + +--- + +### 时间推理查询 + +**Q13**: 用户画像模块最终什么时候上线的? +- 期望:比原计划晚了将近两个月才上线(先定三周后 → 推迟到下下个月 → 最终上线,首日UV2万) +- 考察:跨多条记忆的时间线推理 + +**Q14**: 陈浩什么时候离开极光科技的? +- 期望:拿到offer后下周一入职,今天是最后一天 +- 考察:when_text 的相对时间表达 + +**Q15**: 陈浩妈妈的降压药吃了多久? +- 期望:大约一个月(开了一个月的药,复查后停药) +- 考察:L1 时间跨度推理 + +--- + +### 复合推理查询 + +**Q16**: 陈浩的职业发展轨迹是什么? +- 期望:在极光科技(后端工程师,18k→22k涨薪计划,绩效A)→ 因裁员担忧换工作 → 加入星图数据(25k,试用期转正后28k),技术栈从Python/后端转向Go/Rust +- 考察:跨多条L1+L2的时间线综合 + +**Q17**: 陈浩换工作的原因是什么? +- 期望:公司宣布裁员10%,有担忧;拿到星图数据offer薪资更高(25k) +- 考察:L1情景记忆的因果推理 + +**Q18**: 陈浩现在的生活状态怎么样? +- 期望:住望京、在星图数据工作(28k)、学Rust和Go、游泳、妈妈健康稳定 +- 考察:多个 active L2 fact 的综合呈现 + +**Q19**: 用户画像模块这个项目经历了哪些波折? +- 期望:需求评审有争议 → 定三周后上线 → 推迟到下下个月(前端资源不足)→ 最终上线,首日UV2万 +- 考察:同一主题跨多条记忆的事件串联 + +--- + +### 边界与负样本测试 + +**Q20**: 陈浩有没有去过上海? +- 期望:没有相关记忆 +- 考察:无关记忆时返回 "No relevant memory found" + +**Q21**: 陈浩结婚了吗? +- 期望:没有相关记忆 +- 考察:未提及的个人状态 + +**Q22**: 陈浩今年多少岁? +- 期望:28岁 +- 考察:基础属性直接查询 + +**Q23**: 陈浩的22k涨薪最终兑现了吗? +- 期望:没有明确记录兑现,后来换工作了(该涨薪应已被新薪资覆盖或处于 deprecated 状态) +- 考察:历史 fact 的状态追踪(deprecated 记录) + +**Q24**: 陈浩在哪家医院给妈妈看的病? +- 期望:协和医院 +- 考察:L1 细节字段的精确检索 + +**Q25**: 陈浩的极光科技同事中认识了谁? +- 期望:技术分享会上认识了做推理优化的同事(无具体名字);产品经理李薇 +- 考察:L1 who 字段的检索,及无具名时的处理 diff --git a/tests/seed_memories.sh b/tests/seed_memories.sh new file mode 100755 index 0000000..6b9f30d --- /dev/null +++ b/tests/seed_memories.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# Run this script to seed all test memories defined in memory_qa_test.md. +# Usage: +# bash tests/seed_memories.sh # seed only (keeps existing DB) +# bash tests/seed_memories.sh --clean # delete DB first, then seed + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DB_PATH="${HETA_DB_PATH:-$HOME/.heta/workspace/mem/mem.sqlite3}" + +# ── optional clean ────────────────────────────────────────────────────────── +if [[ "${1:-}" == "--clean" ]]; then + echo ">> Removing existing DB: $DB_PATH" + rm -f "$DB_PATH" "${DB_PATH}-shm" "${DB_PATH}-wal" +fi + +# ── helpers ───────────────────────────────────────────────────────────────── +TOTAL=0 +FAILED=0 +FAILED_TEXTS=() + +run_remember() { + local text="$1" + local label="$2" + TOTAL=$((TOTAL + 1)) + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "[$TOTAL] $label" + echo " $text" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + if ! heta remember "$text"; then + echo "[FAILED] ↑ 上面这条记忆写入失败" + FAILED=$((FAILED + 1)) + FAILED_TEXTS+=("[$TOTAL] $text") + fi +} + +# ── Phase 1: 基础信息建立 ─────────────────────────────────────────────────── +echo "" +echo "============================================================" +echo " 第一阶段:基础信息建立" +echo "============================================================" + +run_remember \ + "我叫陈浩,今年28岁,住在北京朝阳区,在一家叫极光科技的初创公司做后端工程师,入职快两年了" \ + "个人基础信息" + +run_remember \ + "我平时喜欢爬山,周末经常去香山或者云蒙山,还喜欢打羽毛球,偶尔打篮球,最近开始学习游泳" \ + "爱好与运动" + +run_remember \ + "上周去参加了公司的技术分享会,主题是大模型在工程中的落地,大概50人参加,认识了几个做推理优化的同事,收获挺大的" \ + "技术分享会(事件)" + +run_remember \ + "昨天和产品经理李薇开了需求评审会,讨论用户画像模块的改版方案,双方争议挺大,最后定下来计划三周后上线" \ + "需求评审会(事件)" + +run_remember \ + "公司上个季度给我打了A绩效,HR昨天通知我下个月涨薪,从18k涨到22k" \ + "绩效与涨薪(18k→22k)" + +# ── Phase 2: 状态变更(冲突) ─────────────────────────────────────────────── +echo "" +echo "============================================================" +echo " 第二阶段:状态变更(制造冲突)" +echo "============================================================" + +run_remember \ + "我搬家了,从朝阳区搬到了海淀区,现在离公司更近,通勤时间从原来的1小时缩短到大概20分钟" \ + "搬家:朝阳→海淀(冲突住址)" + +run_remember \ + "今天收到消息,用户画像模块上线时间推迟了,改到下下个月,原因是前端人手不够,李薇也很无奈" \ + "项目推迟(冲突上线时间)" + +run_remember \ + "公司全员会议宣布下个季度要裁员10%,部门主管说后端组暂时安全,但我还是有点担心" \ + "裁员公告(事件)" + +run_remember \ + "我决定换工作了,已经拿到星图数据的offer,职位还是后端工程师,薪资直接给到25k,下周一入职" \ + "换工作决定:极光→星图,25k(冲突薪资/公司)" + +run_remember \ + "今天是我在极光科技的最后一天,完成了所有交接,和团队一起吃了散伙饭,挺不舍的" \ + "离职最后一天(事件)" + +# ── Phase 3: 新状态建立 + 更多事件 ───────────────────────────────────────── +echo "" +echo "============================================================" +echo " 第三阶段:新状态建立 + 更多事件" +echo "============================================================" + +run_remember \ + "在星图数据入职了,新公司在北京望京,团队规模比极光科技大很多,主要做企业数据分析产品" \ + "星图数据入职" + +run_remember \ + "上个月和大学同学王强、赵敏、刘洋一起去青岛玩了三天,吃了很多海鲜,住在市南区的民宿,费用AA制大概每人花了1500块" \ + "青岛旅游(事件)" + +run_remember \ + "最近开始学习Rust,打算用来做高性能的数据处理模块,买了《Rust程序设计语言》这本书" \ + "学习Rust" + +run_remember \ + "妈妈最近血压有点高,上周带她去协和医院检查了,医生说要控制饮食、减少盐分,开了一个月的降压药" \ + "妈妈就医(事件)" + +run_remember \ + "星图数据这边技术栈比极光科技更新,主要用Go和Rust,Python只用来做数据脚本,我需要加强Go的经验" \ + "星图技术栈,需学Go" + +# ── Phase 4: 进一步冲突与更新 ────────────────────────────────────────────── +echo "" +echo "============================================================" +echo " 第四阶段:进一步冲突与更新" +echo "============================================================" + +run_remember \ + "我开始学游泳了,报了公司附近游泳馆的课程,教练说我基础不错,一个月后应该能游50米了" \ + "游泳课(与爱好记忆互补)" + +run_remember \ + "搬家了,从海淀区搬到了望京,就在公司附近,步行10分钟就到,房租贵了一些但省去了通勤时间" \ + "再次搬家:海淀→望京(冲突住址+通勤)" + +run_remember \ + "星图数据给我调薪了,从25k涨到28k,理由是试用期表现优秀,正式转正同时调薪" \ + "调薪:25k→28k(冲突薪资)" + +run_remember \ + "用户画像模块终于上线了,比最初计划晚了将近两个月,上线后首日UV达到2万,李薇发微信说很感谢我之前的配合" \ + "项目上线(事件,首日UV2万)" + +run_remember \ + "妈妈复查了,血压已经控制稳定,医生说可以停药了,饮食方面继续保持就行" \ + "妈妈康复(事件,状态更新)" + +# ── 汇总 ──────────────────────────────────────────────────────────────────── +echo "" +echo "============================================================" +echo " 完成" +echo "============================================================" +echo "总计:$TOTAL 条 成功:$((TOTAL - FAILED)) 条 失败:$FAILED 条" + +if [[ $FAILED -gt 0 ]]; then + echo "" + echo "失败列表:" + for t in "${FAILED_TEXTS[@]}"; do + echo " $t" + done + exit 1 +fi diff --git a/tests/test_clean_memory.py b/tests/test_clean_memory.py new file mode 100644 index 0000000..d2a882a --- /dev/null +++ b/tests/test_clean_memory.py @@ -0,0 +1,215 @@ +"""Tests for heta.mem.clean.clean_memory.""" + +from __future__ import annotations + +import time +import uuid +from pathlib import Path + +import pytest + +from heta.mem.clean import clean_memory +from heta.mem.db import get_connection, init_db +from heta.mem.l0_store import insert_turn +from heta.mem.l1_store import insert_episodic +from heta.mem.l2_store import expire_fact, insert_fact +from heta.mem.meta_store import deprecate, insert_meta +from heta.mem.models import L0Turn, L1Episodic, L2Semantic, MemoryMeta, Session +from heta.mem.session_store import close_session, create_session + + +# ── fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture() +def conn(tmp_path: Path): + db = tmp_path / "test_mem.sqlite3" + c = get_connection(db, with_vec=True) + init_db(c) + yield c + c.close() + + +def _new_id() -> str: + return str(uuid.uuid4()) + + +def _now() -> int: + return int(time.time()) + + +def _insert_session(conn, session_id: str | None = None) -> str: + sid = session_id or _new_id() + create_session(conn, Session(session_id=sid, started_at=_now())) + close_session(conn, sid, _now()) + return sid + + +def _insert_l0(conn, session_id: str) -> None: + insert_turn(conn, L0Turn( + session_id=session_id, + turn_index=0, + role="user", + modality="text", + text_content="hello world", + created_at=_now(), + )) + + +def _insert_l2(conn, session_id: str) -> str: + mid = _new_id() + insert_meta(conn, MemoryMeta( + memory_id=mid, memory_type="L2", session_id=session_id, + origin="extracted", created_at=_now(), last_access_at=_now(), + )) + insert_fact(conn, L2Semantic( + memory_id=mid, subject="user", predicate="lives_in", object="Beijing", + object_type="literal", fact_text="user lives_in Beijing", + t_valid_start=_now(), + )) + return mid + + +def _insert_l1(conn, session_id: str) -> str: + mid = _new_id() + insert_meta(conn, MemoryMeta( + memory_id=mid, memory_type="L1", session_id=session_id, + origin="extracted", created_at=_now(), last_access_at=_now(), + )) + insert_episodic(conn, L1Episodic( + memory_id=mid, who='["user"]', what="went to the park", + where_loc="park", when_ts=None, when_text=None, + when_resolved=None, when_precision=None, why=None, + summary="user went to the park", + )) + return mid + + +def _row_count(conn, table: str) -> int: + return conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + + +# ── tests ───────────────────────────────────────────────────────────────────── + +def test_clean_empty_db_is_idempotent(conn) -> None: + result = clean_memory(conn) + assert result.deleted_sessions == 0 + assert result.deleted_l0_turns == 0 + assert result.deleted_l1_episodes == 0 + assert result.deleted_l2_facts == 0 + assert result.deleted_meta == 0 + + +def test_clean_removes_session_and_l0_turns(conn) -> None: + sid = _insert_session(conn) + _insert_l0(conn, sid) + + result = clean_memory(conn) + + assert result.deleted_sessions == 1 + assert result.deleted_l0_turns == 1 + assert _row_count(conn, "session") == 0 + assert _row_count(conn, "l0_turn") == 0 + + +def test_clean_removes_l2_facts_and_meta(conn) -> None: + sid = _insert_session(conn) + _insert_l2(conn, sid) + + result = clean_memory(conn) + + assert result.deleted_l2_facts == 1 + assert result.deleted_meta == 1 + assert _row_count(conn, "l2_semantic") == 0 + assert _row_count(conn, "memory_meta") == 0 + + +def test_clean_removes_l1_episodes_and_meta(conn) -> None: + sid = _insert_session(conn) + _insert_l1(conn, sid) + + result = clean_memory(conn) + + assert result.deleted_l1_episodes == 1 + assert result.deleted_meta == 1 + assert _row_count(conn, "l1_episodic") == 0 + assert _row_count(conn, "memory_meta") == 0 + + +def test_clean_removes_deprecated_facts(conn) -> None: + sid = _insert_session(conn) + old_id = _insert_l2(conn, sid) + new_id = _insert_l2(conn, sid) + expire_fact(conn, old_id, _now()) + deprecate(conn, old_id, new_id) + + assert _row_count(conn, "l2_semantic") == 2 + assert _row_count(conn, "memory_meta") == 2 + + result = clean_memory(conn) + + assert result.deleted_l2_facts == 2 + assert result.deleted_meta == 2 + assert _row_count(conn, "l2_semantic") == 0 + assert _row_count(conn, "memory_meta") == 0 + + +def test_clean_removes_all_layers_together(conn) -> None: + sid = _insert_session(conn) + _insert_l0(conn, sid) + _insert_l1(conn, sid) + _insert_l2(conn, sid) + + result = clean_memory(conn) + + assert result.deleted_sessions == 1 + assert result.deleted_l0_turns == 1 + assert result.deleted_l1_episodes == 1 + assert result.deleted_l2_facts == 1 + assert result.deleted_meta == 2 # one L1 meta + one L2 meta + + +def test_clean_multiple_sessions(conn) -> None: + for _ in range(3): + sid = _insert_session(conn) + _insert_l0(conn, sid) + _insert_l2(conn, sid) + + result = clean_memory(conn) + + assert result.deleted_sessions == 3 + assert result.deleted_l0_turns == 3 + assert result.deleted_l2_facts == 3 + assert _row_count(conn, "session") == 0 + assert _row_count(conn, "l0_turn") == 0 + assert _row_count(conn, "l2_semantic") == 0 + + +def test_clean_preserves_schema(conn) -> None: + """Tables must still exist and accept inserts after a clean.""" + sid = _insert_session(conn) + _insert_l0(conn, sid) + _insert_l1(conn, sid) + _insert_l2(conn, sid) + clean_memory(conn) + + # DB should still be fully usable after clean + sid2 = _insert_session(conn) + _insert_l0(conn, sid2) + _insert_l2(conn, sid2) + + assert _row_count(conn, "session") == 1 + assert _row_count(conn, "l0_turn") == 1 + assert _row_count(conn, "l2_semantic") == 1 + + +def test_clean_is_idempotent(conn) -> None: + sid = _insert_session(conn) + _insert_l0(conn, sid) + _insert_l2(conn, sid) + + first = clean_memory(conn) + second = clean_memory(conn) # called on already-empty DB + + assert first.deleted_sessions == 1 + assert second.deleted_sessions == 0 + assert second.deleted_l2_facts == 0 diff --git a/tests/test_memory_ingest.py b/tests/test_memory_ingest.py new file mode 100644 index 0000000..c9bb51a --- /dev/null +++ b/tests/test_memory_ingest.py @@ -0,0 +1,383 @@ +"""Tests for the heta remember ingestion pipeline (pipeline.py). + +All LLM and embedding calls are mocked so tests run offline and deterministically. +""" + +from __future__ import annotations + +import json +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from heta.config.schema import HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig +from heta.mem.client import EMBEDDING_DIM +from heta.mem.db import get_connection, init_db +from heta.mem.pipeline import remember + + +# ── constants ───────────────────────────────────────────────────────────────── + +FAKE_EMB = [0.01] * EMBEDDING_DIM # deterministic dummy embedding + +EPISODE_DICT = { + "who": ["user"], + "what": "参加了技术分享会", + "where_loc": "公司会议室", + "when_text": "上周", + "when_resolved": "2026-W19", + "when_precision": "week", + "why": None, + "summary": "用户上周在公司会议室参加了技术分享会", +} + +FACT_DICT = { + "subject": "用户", + "predicate": "居住在", + "object": "北京朝阳区", + "object_type": "literal", + "when_text": None, + "when_resolved": None, + "when_precision": None, +} + + +# ── fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture() +def config() -> HetaConfig: + return HetaConfig( + version=1, + llm=LLMConfig(provider="qwen", api_key="sk-test"), + mineru=MinerUConfig.disabled(), + vector_index=VectorIndexConfig.enabled(), + ) + + +@pytest.fixture() +def tmp_db(tmp_path: Path) -> Path: + return tmp_path / "mem.sqlite3" + + +# ── patch helper ────────────────────────────────────────────────────────────── + +@contextmanager +def _patch_pipeline( + tmp_db: Path, + episodes: list[dict] | None = None, + facts: list[dict] | None = None, + conflicts: list[str] | None = None, # memory_ids to deprecate +): + """Patch all external I/O in pipeline.remember so tests run offline.""" + if episodes is None: + episodes = [] + if facts is None: + facts = [] + if conflicts is None: + conflicts = [] + + mock_client = MagicMock() + + def _open_conn(path, *, with_vec=False): + c = get_connection(tmp_db, with_vec=True) + init_db(c) + return c + + with ( + patch("heta.mem.pipeline.ensure_mem_dir"), + patch("heta.mem.pipeline.db_path", return_value=tmp_db), + patch("heta.mem.pipeline.get_connection", side_effect=_open_conn), + patch("heta.mem.pipeline.init_db"), + patch("heta.mem.pipeline.build_client", return_value=(mock_client, "mock-llm")), + patch("heta.mem.pipeline.build_embedding_client", return_value=(mock_client, "mock-emb")), + patch("heta.mem.pipeline.extract_episodes", return_value=episodes), + patch("heta.mem.pipeline.extract_facts", return_value=facts), + patch("heta.mem.pipeline.embed_text", return_value=FAKE_EMB), + patch("heta.mem.pipeline.detect_conflicts", return_value=(conflicts, FAKE_EMB)), + ): + yield + + +def _open(tmp_db: Path): + """Open a fresh read connection to the tmp DB after pipeline closes it.""" + return get_connection(tmp_db, with_vec=True) + + +# ── basic ingestion ─────────────────────────────────────────────────────────── + +def test_remember_creates_session_and_l0_turn(config, tmp_db) -> None: + with _patch_pipeline(tmp_db): + result = remember("hello world", config) + + conn = _open(tmp_db) + sessions = conn.execute("SELECT * FROM session WHERE session_id = ?", (result.session_id,)).fetchall() + turns = conn.execute("SELECT * FROM l0_turn WHERE session_id = ?", (result.session_id,)).fetchall() + conn.close() + + assert len(sessions) == 1 + assert sessions[0]["session_id"] == result.session_id + assert sessions[0]["ended_at"] is not None # session was closed + assert len(turns) == 1 + assert turns[0]["text_content"] == "hello world" + assert turns[0]["role"] == "user" + + +def test_remember_l0_turn_indexed_in_fts(config, tmp_db) -> None: + with _patch_pipeline(tmp_db): + remember("爬山打羽毛球", config) + + conn = _open(tmp_db) + rows = conn.execute( + "SELECT text_content FROM l0_turn_fts WHERE text_content MATCH ?", + ('"爬山打羽毛球"',), + ).fetchall() + conn.close() + + assert len(rows) == 1 + + +def test_remember_empty_extraction_succeeds(config, tmp_db) -> None: + with _patch_pipeline(tmp_db, episodes=[], facts=[]): + result = remember("no events here", config) + + assert result.l1_count == 0 + assert result.l2_count == 0 + + +def test_remember_returns_correct_counts(config, tmp_db) -> None: + with _patch_pipeline(tmp_db, episodes=[EPISODE_DICT], facts=[FACT_DICT, FACT_DICT]): + result = remember("some text", config) + + assert result.l1_count == 1 + assert result.l2_count == 2 + assert result.session_id != "" + assert result.elapsed_s >= 0 + + +# ── L1 episode storage ──────────────────────────────────────────────────────── + +def test_remember_l1_episode_stored_correctly(config, tmp_db) -> None: + with _patch_pipeline(tmp_db, episodes=[EPISODE_DICT]): + result = remember("some text", config) + + conn = _open(tmp_db) + row = conn.execute( + "SELECT * FROM l1_episodic e JOIN memory_meta m ON e.memory_id = m.memory_id " + "WHERE m.session_id = ?", + (result.session_id,), + ).fetchone() + conn.close() + + assert row is not None + assert row["what"] == EPISODE_DICT["what"] + assert row["where_loc"] == EPISODE_DICT["where_loc"] + assert row["when_text"] == EPISODE_DICT["when_text"] + assert row["when_resolved"] == EPISODE_DICT["when_resolved"] + assert row["when_precision"] == EPISODE_DICT["when_precision"] + assert row["summary"] == EPISODE_DICT["summary"] + assert json.loads(row["who"]) == EPISODE_DICT["who"] + assert row["memory_type"] == "L1" + assert row["status"] == "active" + + +def test_remember_l1_episode_embedding_inserted(config, tmp_db) -> None: + with _patch_pipeline(tmp_db, episodes=[EPISODE_DICT]): + result = remember("some text", config) + + conn = _open(tmp_db) + meta = conn.execute( + "SELECT memory_id FROM memory_meta WHERE session_id = ? AND memory_type = 'L1'", + (result.session_id,), + ).fetchone() + vec_row = conn.execute( + "SELECT memory_id FROM l1_episode_vec WHERE memory_id = ?", + (meta["memory_id"],), + ).fetchone() + conn.close() + + assert vec_row is not None + + +def test_remember_l1_who_defaults_to_user_when_missing(config, tmp_db) -> None: + ep = {**EPISODE_DICT} + del ep["who"] + with _patch_pipeline(tmp_db, episodes=[ep]): + remember("some text", config) + + conn = _open(tmp_db) + row = conn.execute("SELECT who FROM l1_episodic").fetchone() + conn.close() + + assert json.loads(row["who"]) == ["user"] + + +# ── L2 fact storage ─────────────────────────────────────────────────────────── + +def test_remember_l2_fact_stored_correctly(config, tmp_db) -> None: + with _patch_pipeline(tmp_db, facts=[FACT_DICT]): + result = remember("some text", config) + + conn = _open(tmp_db) + row = conn.execute( + "SELECT * FROM l2_semantic s JOIN memory_meta m ON s.memory_id = m.memory_id " + "WHERE m.session_id = ?", + (result.session_id,), + ).fetchone() + conn.close() + + assert row is not None + assert row["subject"] == FACT_DICT["subject"] + assert row["predicate"] == FACT_DICT["predicate"] + assert row["object"] == FACT_DICT["object"] + assert row["object_type"] == "literal" + assert row["t_valid_end"] is None # active, not yet expired + assert row["status"] == "active" + assert row["memory_type"] == "L2" + + +def test_remember_l2_fact_embedding_inserted(config, tmp_db) -> None: + with _patch_pipeline(tmp_db, facts=[FACT_DICT]): + result = remember("some text", config) + + conn = _open(tmp_db) + meta = conn.execute( + "SELECT memory_id FROM memory_meta WHERE session_id = ? AND memory_type = 'L2'", + (result.session_id,), + ).fetchone() + vec_row = conn.execute( + "SELECT memory_id FROM l2_fact_vec WHERE memory_id = ?", + (meta["memory_id"],), + ).fetchone() + conn.close() + + assert vec_row is not None + + +def test_remember_l2_object_type_list_coerced_to_string(config, tmp_db) -> None: + """LLM occasionally returns object_type as a list; pipeline should normalise it.""" + fact = {**FACT_DICT, "object_type": ["literal", "extra"]} + with _patch_pipeline(tmp_db, facts=[fact]): + remember("some text", config) + + conn = _open(tmp_db) + row = conn.execute("SELECT object_type FROM l2_semantic").fetchone() + conn.close() + + assert isinstance(row["object_type"], str) + assert row["object_type"] == "literal" + + +# ── conflict resolution ─────────────────────────────────────────────────────── + +def test_remember_conflict_deprecates_old_fact(config, tmp_db) -> None: + """When detect_conflicts returns an old memory_id, that fact is expired + deprecated.""" + # session 1: insert a fact directly so we have an old_id to conflict + from heta.mem.l2_store import insert_fact, insert_fact_embedding + from heta.mem.meta_store import insert_meta + from heta.mem.models import L2Semantic, MemoryMeta + from heta.mem.session_store import create_session, close_session + from heta.mem.models import Session + import time, uuid + + old_id = str(uuid.uuid4()) + now = int(time.time()) + + setup_conn = get_connection(tmp_db, with_vec=True) + init_db(setup_conn) + sid0 = str(uuid.uuid4()) + create_session(setup_conn, Session(session_id=sid0, started_at=now)) + close_session(setup_conn, sid0, now) + insert_meta(setup_conn, MemoryMeta( + memory_id=old_id, memory_type="L2", session_id=sid0, + origin="extracted", created_at=now, last_access_at=now, + )) + insert_fact(setup_conn, L2Semantic( + memory_id=old_id, subject="用户", predicate="居住在", object="北京朝阳区", + object_type="literal", fact_text="用户 居住在 北京朝阳区", + t_valid_start=now, + )) + insert_fact_embedding(setup_conn, old_id, FAKE_EMB) + setup_conn.close() + + # session 2: new fact conflicts with old_id + new_fact = {**FACT_DICT, "object": "北京海淀区"} + with _patch_pipeline(tmp_db, facts=[new_fact], conflicts=[old_id]): + remember("搬家了", config) + + conn = _open(tmp_db) + old_row = conn.execute( + "SELECT s.t_valid_end, m.status, m.deprecated_by " + "FROM l2_semantic s JOIN memory_meta m ON s.memory_id = m.memory_id " + "WHERE s.memory_id = ?", + (old_id,), + ).fetchone() + active_rows = conn.execute( + "SELECT object FROM l2_semantic WHERE t_valid_end IS NULL" + ).fetchall() + conn.close() + + assert old_row["t_valid_end"] is not None # expired + assert old_row["status"] == "deprecated" + assert old_row["deprecated_by"] is not None # FK to new fact + assert len(active_rows) == 1 + assert active_rows[0]["object"] == "北京海淀区" + + +def test_remember_no_conflict_keeps_both_facts(config, tmp_db) -> None: + """When detect_conflicts returns [], both old and new facts remain active.""" + fact_a = {**FACT_DICT, "predicate": "喜欢", "object": "爬山"} + fact_b = {**FACT_DICT, "predicate": "喜欢", "object": "羽毛球"} + + with _patch_pipeline(tmp_db, facts=[fact_a], conflicts=[]): + remember("喜欢爬山", config) + with _patch_pipeline(tmp_db, facts=[fact_b], conflicts=[]): + remember("喜欢羽毛球", config) + + conn = _open(tmp_db) + active = conn.execute( + "SELECT object FROM l2_semantic WHERE t_valid_end IS NULL ORDER BY object" + ).fetchall() + conn.close() + + objects = {r["object"] for r in active} + assert objects == {"爬山", "羽毛球"} + + +def test_remember_detect_conflicts_receives_session_id(config, tmp_db) -> None: + """detect_conflicts must be called with the current session_id so same-session + facts are excluded from conflict candidates.""" + captured_kwargs: dict = {} + + def _fake_detect_conflicts(**kwargs): + captured_kwargs.update(kwargs) + return ([], FAKE_EMB) + + with ( + _patch_pipeline(tmp_db, facts=[FACT_DICT]), + patch("heta.mem.pipeline.detect_conflicts", side_effect=_fake_detect_conflicts), + ): + result = remember("some text", config) + + assert "session_id" in captured_kwargs + assert captured_kwargs["session_id"] == result.session_id + + +# ── multiple sessions ───────────────────────────────────────────────────────── + +def test_remember_multiple_sessions_accumulate(config, tmp_db) -> None: + with _patch_pipeline(tmp_db, facts=[FACT_DICT]): + r1 = remember("first", config) + with _patch_pipeline(tmp_db, facts=[FACT_DICT]): + r2 = remember("second", config) + + assert r1.session_id != r2.session_id + + conn = _open(tmp_db) + n_sessions = conn.execute("SELECT COUNT(*) FROM session").fetchone()[0] + n_turns = conn.execute("SELECT COUNT(*) FROM l0_turn").fetchone()[0] + conn.close() + + assert n_sessions == 2 + assert n_turns == 2 From c9d61b2974d9f6f7767a45ff69c8965c103ed5a9 Mon Sep 17 00:00:00 2001 From: 77one Date: Wed, 13 May 2026 19:07:02 +0800 Subject: [PATCH 03/44] fix: harden pdf insert pipeline --- .../pdf_planning_agent_flow.md | 28 ++++++++-------- src/heta/kb/agent.py | 33 +++++++++++-------- src/heta/kb/insert.py | 3 +- src/heta/kb/pdf_plan.py | 2 +- src/heta/kb/wiki.py | 27 +++++++++++++++ tests/test_kb_insert.py | 29 +++++++++++++++- tests/test_pdf_plan.py | 16 ++++----- 7 files changed, 99 insertions(+), 39 deletions(-) diff --git a/docs/technical-explanation/pdf_planning_agent_flow.md b/docs/technical-explanation/pdf_planning_agent_flow.md index 0008b42..10e7717 100644 --- a/docs/technical-explanation/pdf_planning_agent_flow.md +++ b/docs/technical-explanation/pdf_planning_agent_flow.md @@ -26,10 +26,10 @@ flowchart TD J --> K["run_pdf_planning_agent"] K --> L{"agent plan valid?"} - L -->|No| M["fallback plan
fixed 40-page windows"] + L -->|No| M["fallback plan
fixed 20-page windows"] L -->|Yes| N["validate and normalize plan"] - N --> O["split oversized units
max 40 pages each"] + N --> O["split oversized units
max 20 pages each"] O --> P["fill missing page ranges"] P --> Q["remove overlaps by sorting and cropping"] @@ -153,8 +153,8 @@ flowchart TD D -->|No| F D -->|Yes| E["normalize units"] - E --> G{"unit > 40 pages?"} - G -->|Yes| H["split oversized unit
into <=40-page parts"] + E --> G{"unit > 20 pages?"} + G -->|Yes| H["split oversized unit
into <=20-page parts"] G -->|No| I["keep unit"] H --> J["sort units by start_page"] I --> J @@ -177,7 +177,7 @@ Validation rules: - Oversized units are split into smaller parts. - Missing page ranges are filled automatically. - Overlapping ranges are cropped after sorting. -- If the plan is invalid, Little Heta falls back to fixed 40-page windows. +- If the plan is invalid, Little Heta falls back to fixed 20-page windows. ## Split Output @@ -188,10 +188,10 @@ raw/ originals/ 2026-05-13_143000_big-book.pdf - 2026-05-13_143000_big-book_part-001_intro_pages-1-40.pdf - 2026-05-13_143000_big-book_part-001_intro_pages-1-40.meta.json - 2026-05-13_143000_big-book_part-002_methods_pages-41-80.pdf - 2026-05-13_143000_big-book_part-002_methods_pages-41-80.meta.json + 2026-05-13_143000_big-book_part-001_intro_pages-1-20.pdf + 2026-05-13_143000_big-book_part-001_intro_pages-1-20.meta.json + 2026-05-13_143000_big-book_part-002_methods_pages-21-40.pdf + 2026-05-13_143000_big-book_part-002_methods_pages-21-40.meta.json ``` Each `.meta.json` records traceability: @@ -199,10 +199,10 @@ Each `.meta.json` records traceability: ```json { "original": "raw/originals/2026-05-13_143000_big-book.pdf", - "part": "raw/2026-05-13_143000_big-book_part-001_intro_pages-1-40.pdf", + "part": "raw/2026-05-13_143000_big-book_part-001_intro_pages-1-20.pdf", "title": "Introduction", "start_page": 1, - "end_page": 40, + "end_page": 20, "document_type": "report", "split_strategy": "section" } @@ -213,9 +213,9 @@ Each `.meta.json` records traceability: Fallback is intentionally simple and deterministic: ```text -Pages 1-40 -Pages 41-80 -Pages 81-120 +Pages 1-20 +Pages 21-40 +Pages 41-60 ... ``` diff --git a/src/heta/kb/agent.py b/src/heta/kb/agent.py index ee392d1..832ceec 100644 --- a/src/heta/kb/agent.py +++ b/src/heta/kb/agent.py @@ -388,20 +388,25 @@ def _execute_tools( except json.JSONDecodeError as exc: output = f"error: invalid tool arguments: {exc}" else: - if name == "read_page": - output = read_page(root_dir, **arguments) - if not output.startswith("error:"): - read_paths.add(_normalize_path(arguments.get("path", ""))) - elif name == "create_page": - output = create_page(root_dir, written_paths=written_paths, **arguments) - elif name == "edit_page": - output = edit_page(root_dir, written_paths=written_paths, **arguments) - elif name == "delete_page": - output = delete_page(root_dir, written_paths=written_paths, **arguments) - elif name == "append_log": - output = append_log(root_dir, **arguments) - else: - output = f"error: unknown tool {name}" + try: + if name == "read_page": + output = read_page(root_dir, **arguments) + if not output.startswith("error:"): + read_paths.add(_normalize_path(arguments.get("path", ""))) + elif name == "create_page": + output = create_page(root_dir, written_paths=written_paths, **arguments) + elif name == "edit_page": + output = edit_page(root_dir, written_paths=written_paths, **arguments) + elif name == "delete_page": + output = delete_page(root_dir, written_paths=written_paths, **arguments) + elif name == "append_log": + output = append_log(root_dir, **arguments) + else: + output = f"error: unknown tool {name}" + except TypeError as exc: + output = f"error: invalid tool arguments for {name}: {exc}" + except Exception as exc: + output = f"error: tool {name} failed: {exc}" results.append({"role": "tool", "tool_call_id": tool_call.id, "content": output}) return results diff --git a/src/heta/kb/insert.py b/src/heta/kb/insert.py index 400f7d5..c823ae7 100644 --- a/src/heta/kb/insert.py +++ b/src/heta/kb/insert.py @@ -14,7 +14,7 @@ from heta.kb.pdf_plan import plan_insert_files from heta.kb.store import commit_wiki, ensure_wiki_layout, reset_wiki from heta.kb.vector_index import sync_wiki_vector_index -from heta.kb.wiki import apply_path_map, normalize_wiki_pages, validate_wiki +from heta.kb.wiki import apply_path_map, normalize_wiki_pages, repair_broken_wiki_links, validate_wiki from heta.kb.workspace import cleanup_working_copy, create_working_copy, promote_working_copy @@ -66,6 +66,7 @@ def insert_paths( if not (agent_result["added"] or agent_result["updated"] or agent_result["deleted"]): raise RuntimeError("Agent completed without changing the wiki.") normalize_result = normalize_wiki_pages(working_wiki) + repair_broken_wiki_links(working_wiki) validate_wiki(working_wiki) promote_working_copy(task_id, base_dir) commit_id = commit_wiki(f"ingest: {', '.join(file.name for file in files)}", base_dir) diff --git a/src/heta/kb/pdf_plan.py b/src/heta/kb/pdf_plan.py index 979804a..98edd16 100644 --- a/src/heta/kb/pdf_plan.py +++ b/src/heta/kb/pdf_plan.py @@ -18,7 +18,7 @@ from heta.kb.text import slugify PDF_PAGE_THRESHOLD = 80 -PDF_PART_MAX_PAGES = 40 +PDF_PART_MAX_PAGES = 20 PDF_PROFILE_MAX_CHARS = 12000 diff --git a/src/heta/kb/wiki.py b/src/heta/kb/wiki.py index 9e1e80a..469b6a6 100644 --- a/src/heta/kb/wiki.py +++ b/src/heta/kb/wiki.py @@ -103,6 +103,33 @@ def validate_wiki(wiki_root: Path) -> None: raise ValueError(f"broken wiki link in {page.name}: {link}") +def repair_broken_wiki_links(wiki_root: Path) -> None: + """Downgrade broken wiki links to plain text before validation.""" + pages = wiki_root / "pages" + if not pages.exists(): + return + + page_titles = set() + for page in pages.glob("*.md"): + text = page.read_text(encoding="utf-8") + title = _frontmatter_value(text, "title") or page.stem + page_titles.add(title.strip().lower()) + page_titles.add(page.stem.strip().lower()) + + for page in pages.glob("*.md"): + text = page.read_text(encoding="utf-8") + + def replace(match: re.Match[str]) -> str: + link = match.group(1).strip() + if link.lower() in page_titles: + return match.group(0) + return link + + repaired = re.sub(r"\[\[([^\]]+)\]\]", replace, text) + if repaired != text: + page.write_text(repaired, encoding="utf-8") + + def normalize_wiki_pages(wiki_root: Path) -> NormalizeResult: """Assign numeric page filename prefixes and rewrite index paths. diff --git a/tests/test_kb_insert.py b/tests/test_kb_insert.py index bd9a8a0..ad69c92 100644 --- a/tests/test_kb_insert.py +++ b/tests/test_kb_insert.py @@ -7,7 +7,7 @@ from heta.kb.models import FileChange from heta.kb.insert import insert_paths from heta.kb.text import frontmatter_page, slugify, summarize -from heta.kb.wiki import normalize_wiki_pages +from heta.kb.wiki import normalize_wiki_pages, repair_broken_wiki_links def _config(mineru: MinerUConfig | None = None) -> HetaConfig: @@ -142,3 +142,30 @@ def test_normalize_wiki_pages_assigns_max_plus_one_without_reusing_deleted_ids(t assert "- [1] [[Old]] (pages/1-old.md) — Old summary." in index assert "- [3] [[Existing]] (pages/3-existing.md) — Existing summary." in index assert "- [4] [[New Topic]] (pages/4-new-topic.md) — New summary." in index + + +def test_repair_broken_wiki_links_downgrades_missing_targets(tmp_path: Path) -> None: + wiki = tmp_path / "wiki" + pages = wiki / "pages" + pages.mkdir(parents=True) + page = pages / "1-topic.md" + page.write_text( + frontmatter_page( + "Topic", + "source.md", + "Summary.", + "See [[Existing Topic]] and [[Missing Topic]].", + ), + encoding="utf-8", + ) + (pages / "2-existing-topic.md").write_text( + frontmatter_page("Existing Topic", "source.md", "Summary.", "Body."), + encoding="utf-8", + ) + + repair_broken_wiki_links(wiki) + + text = page.read_text(encoding="utf-8") + assert "[[Existing Topic]]" in text + assert "[[Missing Topic]]" not in text + assert "Missing Topic" in text diff --git a/tests/test_pdf_plan.py b/tests/test_pdf_plan.py index 1dee6dd..00cea48 100644 --- a/tests/test_pdf_plan.py +++ b/tests/test_pdf_plan.py @@ -17,11 +17,11 @@ def test_plan_insert_files_splits_large_pdf_and_keeps_original(tmp_path: Path) - assert len(plans) == 1 assert plans[0].enabled is True assert plans[0].page_count == PDF_PAGE_THRESHOLD + 1 - assert plans[0].parts == 3 - assert len(prepared) == 3 + assert plans[0].parts == 5 + assert len(prepared) == 5 assert (paths.raw_dir(tmp_path) / "originals").exists() assert all(item.archived_path.exists() for item in prepared) - assert [estimate_pdf_pages(item.archived_path) for item in prepared] == [40, 40, 1] + assert [estimate_pdf_pages(item.archived_path) for item in prepared] == [20, 20, 20, 20, 1] assert all(item.original_path is not None for item in prepared) assert all(item.metadata_path is not None and item.metadata_path.exists() for item in prepared) @@ -60,12 +60,12 @@ def test_plan_insert_files_uses_agent_split_plan(monkeypatch, tmp_path: Path) -> assert plans[0].document_type == "textbook" assert plans[0].split_strategy == "chapter" - assert [item.page_start for item in prepared] == [1, 31, 71] - assert [item.page_end for item in prepared] == [30, 70, 81] + assert [item.page_start for item in prepared] == [1, 21, 31, 51, 71] + assert [item.page_end for item in prepared] == [20, 30, 50, 70, 81] metadata = json.loads(prepared[0].metadata_path.read_text(encoding="utf-8")) assert metadata["original"].endswith("large.pdf") assert metadata["start_page"] == 1 - assert metadata["end_page"] == 30 + assert metadata["end_page"] == 20 assert metadata["split_strategy"] == "chapter" @@ -84,7 +84,7 @@ def test_plan_insert_files_falls_back_when_agent_plan_is_invalid(monkeypatch, tm prepared, plans = plan_insert_files([source], config=_config(), base_dir=tmp_path) assert plans[0].split_strategy == "fixed_page_window" - assert [estimate_pdf_pages(item.archived_path) for item in prepared] == [40, 40, 1] + assert [estimate_pdf_pages(item.archived_path) for item in prepared] == [20, 20, 20, 20, 1] def test_plan_insert_files_fills_pages_missing_from_agent_plan(monkeypatch, tmp_path: Path) -> None: @@ -102,7 +102,7 @@ def test_plan_insert_files_fills_pages_missing_from_agent_plan(monkeypatch, tmp_ prepared, plans = plan_insert_files([source], config=_config(), base_dir=tmp_path) assert plans[0].split_strategy == "section" - assert [(item.page_start, item.page_end) for item in prepared] == [(1, 40), (41, 60), (61, 81)] + assert [(item.page_start, item.page_end) for item in prepared] == [(1, 20), (21, 40), (41, 60), (61, 80), (81, 81)] def _write_pdf(path: Path, pages: int) -> None: From bc9f5cf4ce67517869e39128b5502582621ae215 Mon Sep 17 00:00:00 2001 From: 77one Date: Wed, 13 May 2026 19:27:29 +0800 Subject: [PATCH 04/44] feat: process insert sources sequentially --- src/heta/kb/agent.py | 15 +++++++++++++-- src/heta/kb/insert.py | 33 +++++++++++++++++++-------------- tests/test_kb_insert.py | 23 ++++++++++++++++++++++- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/heta/kb/agent.py b/src/heta/kb/agent.py index 832ceec..23c9328 100644 --- a/src/heta/kb/agent.py +++ b/src/heta/kb/agent.py @@ -303,13 +303,17 @@ def _system_prompt() -> str: Your job is to absorb parsed source documents into the Markdown wiki working copy. You must use tools to inspect and edit files. Do not claim completion until the working copy contains the final wiki changes. +In normal ingest, you receive one source document at a time. Treat that document +as the current unit of truth and preserve its concrete details in the wiki. Required workflow: 1. read_page("index.md") to understand the current wiki. 2. Identify up to 5 related pages from index.md for each source document. 3. read_page each related page before deciding whether it is genuinely related. 4. For each source document, either create one complete new page or edit one - existing page that already covers the same topic. + existing page that already covers the same topic. When editing an existing + page, add or extend a source-specific section unless the current source is + genuinely duplicate. 5. Update index.md with one entry per created or substantially updated page. The entry must use exactly this format: - [id] [[Title]] (pages/file-name.md) — one-line summary @@ -330,7 +334,9 @@ def _system_prompt() -> str: One paragraph. ## Content -Full self-contained content. +Full self-contained content. Preserve the source document's definitions, +examples, procedures, named entities, important lists, formulas, constraints, +and concrete facts. Do not replace the source with a high-level summary. ## Related Pages - [[Related Title]] @@ -343,6 +349,11 @@ def _system_prompt() -> str: Rules: - Paths are limited to index.md, log.md, and pages/*.md. - One source document becomes one complete wiki page unless it clearly belongs in an existing page. +- Every source document must be represented in page content and in the Source list. +- Merge overlapping sources only when they describe the same thing; even then, + keep new details from the current source. +- Do not discard details just because they seem minor or because the page already + has a summary. - Do not invent or maintain wiki ids, chunk ids, or numeric page prefixes. - Keep [[Wiki Links]] semantic, e.g. [[HetaGen]], never [[1-HetaGen]]. - Always read a page before editing it. diff --git a/src/heta/kb/insert.py b/src/heta/kb/insert.py index c823ae7..9429881 100644 --- a/src/heta/kb/insert.py +++ b/src/heta/kb/insert.py @@ -57,22 +57,27 @@ def insert_paths( parsed_documents.append(parse_document(source.source_path, source.archived_path, config)) working_wiki = create_working_copy(task_id, base_dir) - agent_result = run_merge_agent( - task_id=task_id, - documents=parsed_documents, - root_dir=working_wiki, - config=config, - ) - if not (agent_result["added"] or agent_result["updated"] or agent_result["deleted"]): - raise RuntimeError("Agent completed without changing the wiki.") - normalize_result = normalize_wiki_pages(working_wiki) - repair_broken_wiki_links(working_wiki) - validate_wiki(working_wiki) + added = [] + updated = [] + deleted = [] + for index, document in enumerate(parsed_documents, start=1): + agent_result = run_merge_agent( + task_id=f"{task_id}_{index}", + documents=[document], + root_dir=working_wiki, + config=config, + ) + if not (agent_result["added"] or agent_result["updated"] or agent_result["deleted"]): + raise RuntimeError(f"Agent completed without changing the wiki for: {document.source_name}") + normalize_result = normalize_wiki_pages(working_wiki) + repair_broken_wiki_links(working_wiki) + validate_wiki(working_wiki) + added.extend(apply_path_map(agent_result["added"], normalize_result.path_map)) + updated.extend(apply_path_map(agent_result["updated"], normalize_result.path_map)) + deleted.extend(apply_path_map(agent_result["deleted"], normalize_result.path_map)) + promote_working_copy(task_id, base_dir) commit_id = commit_wiki(f"ingest: {', '.join(file.name for file in files)}", base_dir) - added = apply_path_map(agent_result["added"], normalize_result.path_map) - updated = apply_path_map(agent_result["updated"], normalize_result.path_map) - deleted = agent_result["deleted"] if config.vector_index.enable: try: sync_wiki_vector_index( diff --git a/tests/test_kb_insert.py b/tests/test_kb_insert.py index ad69c92..6cc9e40 100644 --- a/tests/test_kb_insert.py +++ b/tests/test_kb_insert.py @@ -19,8 +19,11 @@ def _config(mineru: MinerUConfig | None = None) -> HetaConfig: ) -def _fake_agent(monkeypatch) -> None: +def _fake_agent(monkeypatch, calls: list[list[str]] | None = None) -> None: def run_merge_agent(*, task_id, documents, root_dir, config): + assert len(documents) == 1 + if calls is not None: + calls.append([document.source_name for document in documents]) pages = root_dir / "pages" pages.mkdir(parents=True, exist_ok=True) added = [] @@ -96,6 +99,24 @@ def test_insert_same_title_updates_existing_page(monkeypatch, tmp_path: Path) -> assert "## Imported Update" in page.read_text(encoding="utf-8") +def test_insert_multiple_files_runs_agent_sequentially(monkeypatch, tmp_path: Path) -> None: + calls: list[list[str]] = [] + _fake_agent(monkeypatch, calls) + first = tmp_path / "alpha.md" + second = tmp_path / "beta.md" + first.write_text("# Alpha\n\nFirst details.", encoding="utf-8") + second.write_text("# Beta\n\nSecond details.", encoding="utf-8") + + result = insert_paths([first, second], _config(), base_dir=tmp_path) + + wiki = tmp_path / "workspace" / "kb" / "wiki" + assert calls[0][0].endswith("_alpha.md") + assert calls[1][0].endswith("_beta.md") + assert (wiki / "pages" / "1-alpha.md").exists() + assert (wiki / "pages" / "2-beta.md").exists() + assert [change.path for change in result.added] == ["pages/1-alpha.md", "pages/2-beta.md"] + + def test_pdf_requires_mineru_when_disabled(tmp_path: Path) -> None: source = tmp_path / "paper.pdf" source.write_bytes(b"%PDF") From 455b104ab49369e785a2413e196b96a78054649d Mon Sep 17 00:00:00 2001 From: 77one Date: Wed, 13 May 2026 19:55:20 +0800 Subject: [PATCH 05/44] fix: deduplicate wiki vector sync changes --- src/heta/kb/vector_index.py | 13 ++++++---- tests/test_vector_index.py | 49 ++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/heta/kb/vector_index.py b/src/heta/kb/vector_index.py index 1c1bc03..c160025 100644 --- a/src/heta/kb/vector_index.py +++ b/src/heta/kb/vector_index.py @@ -73,18 +73,21 @@ def sync_wiki_vector_index( try: _ensure_schema(conn) changed = list(changes) - pages_to_embed: list[Path] = [] + deleted_wiki_ids: set[int] = set() + pages_to_embed: dict[int, Path] = {} for change in changed: wiki_id = _wiki_id_from_path(change.path) if wiki_id is not None: - _delete_page_chunks(conn, wiki_id) + if wiki_id not in deleted_wiki_ids: + _delete_page_chunks(conn, wiki_id) + deleted_wiki_ids.add(wiki_id) if change.kind == "deleted": continue page = paths.wiki_dir(base_dir) / change.path - if page.exists(): - pages_to_embed.append(page) + if page.exists() and wiki_id is not None: + pages_to_embed[wiki_id] = page - chunks = [chunk for page in pages_to_embed for chunk in chunk_wiki_page(page)] + chunks = [chunk for page in pages_to_embed.values() for chunk in chunk_wiki_page(page)] if chunks: embeddings = _embed_texts([chunk.content for chunk in chunks], config) for chunk, embedding in zip(chunks, embeddings, strict=True): diff --git a/tests/test_vector_index.py b/tests/test_vector_index.py index e945d1e..0b3cc21 100644 --- a/tests/test_vector_index.py +++ b/tests/test_vector_index.py @@ -7,7 +7,14 @@ from heta.config.schema import HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig from heta.kb import paths -from heta.kb.vector_index import _ensure_schema, _insert_chunk, chunk_wiki_page, search_wiki_vector_index +from heta.kb.models import FileChange +from heta.kb.vector_index import ( + _ensure_schema, + _insert_chunk, + chunk_wiki_page, + search_wiki_vector_index, + sync_wiki_vector_index, +) def test_chunk_wiki_page_uses_heading_path_and_page_context(tmp_path: Path) -> None: @@ -88,6 +95,46 @@ def test_search_wiki_vector_index_returns_ranked_chunks(monkeypatch, tmp_path: P assert results[0].score == 1.0 +def test_sync_wiki_vector_index_deduplicates_repeated_page_changes(monkeypatch, tmp_path: Path) -> None: + _write_page( + tmp_path, + "1-hetagen.md", + "HetaGen", + "Structured content generation.", + """ +### Capabilities + +HetaGen supports table synthesis. + +### Query + +HetaGen can answer structured questions. +""", + ) + monkeypatch.setattr("heta.kb.vector_index._embed_texts", lambda texts, config: [[1.0] + [0.0] * 1023 for _ in texts]) + config = HetaConfig( + version=1, + llm=LLMConfig(provider="qwen", api_key="sk-test"), + mineru=MinerUConfig.disabled(), + vector_index=VectorIndexConfig.enabled(), + ) + + sync_wiki_vector_index( + changes=[ + FileChange("added", "HetaGen", "pages/1-hetagen.md"), + FileChange("updated", "HetaGen", "pages/1-hetagen.md"), + ], + config=config, + base_dir=tmp_path, + ) + + conn = sqlite3.connect(paths.vector_db_path(tmp_path)) + try: + assert conn.execute("SELECT count(*) FROM wiki_chunks").fetchone()[0] == 2 + finally: + conn.close() + + def _write_page(tmp_path: Path, name: str, title: str, summary: str, content: str) -> Path: page = paths.pages_dir(tmp_path) / name page.parent.mkdir(parents=True) From acc27d57ac7972f6fac95b2d1923c37ff257269c Mon Sep 17 00:00:00 2001 From: 77one Date: Thu, 14 May 2026 11:17:17 +0800 Subject: [PATCH 06/44] feat: add insert merge progress bar --- src/heta/cli/insert.py | 36 +++++++++++++++++++++++-- src/heta/kb/insert.py | 60 +++++++++++++++++++++++++++++++++++++++-- src/heta/kb/models.py | 9 +++++++ tests/test_kb_insert.py | 14 +++++++++- 4 files changed, 114 insertions(+), 5 deletions(-) diff --git a/src/heta/cli/insert.py b/src/heta/cli/insert.py index b3d3474..256ecd5 100644 --- a/src/heta/cli/insert.py +++ b/src/heta/cli/insert.py @@ -7,12 +7,14 @@ import typer from rich.console import Console from rich.panel import Panel +from rich.progress import BarColumn, Progress, TaskProgressColumn, TextColumn from rich.table import Table from heta.config.io import CONFIG_PATH, load_config from heta.kb import paths from heta.kb.discovery import collect_insert_files, supported_extensions from heta.kb.insert import insert_paths +from heta.kb.models import InsertProgress from heta.kb.pdf_plan import PDF_PAGE_THRESHOLD, estimate_pdf_pages console = Console() @@ -57,8 +59,18 @@ def insert_command( _show_plan(files, config, pdf_planning=pdf_planning) try: - with console.status(f"[bold {HETA}]heta insert[/] [{MUTED}]parsing files and merging wiki[/]", spinner="dots"): - result = insert_paths(targets or [], config, enable_pdf_planning=pdf_planning) + with _insert_progress() as progress: + task_id = progress.add_task("preparing insert", total=100, completed=0) + + def on_progress(event: InsertProgress) -> None: + progress.update(task_id, completed=event.percent, description=_progress_description(event)) + + result = insert_paths( + targets or [], + config, + enable_pdf_planning=pdf_planning, + on_progress=on_progress, + ) except KeyboardInterrupt: console.print(f"\n[{WARN}]Insert cancelled. Rolled back partial changes.[/]") raise typer.Exit(130) from None @@ -124,6 +136,26 @@ def _show_result(result) -> None: console.print(f"[{MUTED}]pdf parts:[/] {result.planned_pdf_parts}") +def _insert_progress() -> Progress: + return Progress( + TextColumn(f"[bold {HETA}]heta insert[/]"), + BarColumn(bar_width=28, complete_style=HETA, finished_style=OK), + TaskProgressColumn(), + TextColumn("[dim]{task.description}[/]"), + console=console, + ) + + +def _progress_description(event: InsertProgress) -> str: + if event.phase == "merge": + return f"merging {event.current}/{event.total} · {event.label}" + if event.phase == "finalize": + return "finalizing wiki, vector index, and commit" + if event.phase == "done": + return "done" + return event.label + + def _absolute_page_path(relative_path: str) -> str: return str((paths.wiki_dir() / relative_path).resolve()) diff --git a/src/heta/kb/insert.py b/src/heta/kb/insert.py index 9429881..0122219 100644 --- a/src/heta/kb/insert.py +++ b/src/heta/kb/insert.py @@ -2,14 +2,15 @@ from __future__ import annotations +from collections.abc import Callable from datetime import datetime from pathlib import Path from uuid import uuid4 from heta.config.schema import HetaConfig -from heta.kb.discovery import collect_insert_files from heta.kb.agent import run_merge_agent -from heta.kb.models import InsertResult, ParsedDocument +from heta.kb.discovery import collect_insert_files +from heta.kb.models import InsertProgress, InsertResult, ParsedDocument from heta.kb.parser import parse_document from heta.kb.pdf_plan import plan_insert_files from heta.kb.store import commit_wiki, ensure_wiki_layout, reset_wiki @@ -24,6 +25,7 @@ def insert_paths( *, base_dir: Path | None = None, enable_pdf_planning: bool = True, + on_progress: Callable[[InsertProgress], None] | None = None, ) -> InsertResult: files = collect_insert_files(targets, config) if not files: @@ -57,10 +59,20 @@ def insert_paths( parsed_documents.append(parse_document(source.source_path, source.archived_path, config)) working_wiki = create_working_copy(task_id, base_dir) + total_documents = len(parsed_documents) + _emit_progress(on_progress, "merge", 1, 0, total_documents, "ready to merge documents") added = [] updated = [] deleted = [] for index, document in enumerate(parsed_documents, start=1): + _emit_progress( + on_progress, + "merge", + _merge_percent(index - 1, total_documents), + index - 1, + total_documents, + document.source_name, + ) agent_result = run_merge_agent( task_id=f"{task_id}_{index}", documents=[document], @@ -75,7 +87,23 @@ def insert_paths( added.extend(apply_path_map(agent_result["added"], normalize_result.path_map)) updated.extend(apply_path_map(agent_result["updated"], normalize_result.path_map)) deleted.extend(apply_path_map(agent_result["deleted"], normalize_result.path_map)) + _emit_progress( + on_progress, + "merge", + _merge_percent(index, total_documents), + index, + total_documents, + document.source_name, + ) + _emit_progress( + on_progress, + "finalize", + 99, + total_documents, + total_documents, + "finalizing wiki and vector index", + ) promote_working_copy(task_id, base_dir) commit_id = commit_wiki(f"ingest: {', '.join(file.name for file in files)}", base_dir) if config.vector_index.enable: @@ -88,6 +116,7 @@ def insert_paths( except Exception: pass cleanup_working_copy(task_id, base_dir) + _emit_progress(on_progress, "done", 100, total_documents, total_documents, "insert completed") return InsertResult( commit_id=commit_id, @@ -104,3 +133,30 @@ def insert_paths( cleanup_working_copy(task_id, base_dir) reset_wiki(base_dir) raise + + +def _merge_percent(done: int, total: int) -> int: + if total <= 0: + return 99 + return min(99, 1 + int(done / total * 98)) + + +def _emit_progress( + callback: Callable[[InsertProgress], None] | None, + phase: str, + percent: int, + current: int, + total: int, + label: str, +) -> None: + if callback is None: + return + callback( + InsertProgress( + phase=phase, + percent=max(0, min(100, percent)), + current=current, + total=total, + label=label, + ) + ) diff --git a/src/heta/kb/models.py b/src/heta/kb/models.py index 733afe0..c65a221 100644 --- a/src/heta/kb/models.py +++ b/src/heta/kb/models.py @@ -31,3 +31,12 @@ class InsertResult: deleted: list[FileChange] raw_files: list[Path] planned_pdf_parts: int = 0 + + +@dataclass(frozen=True) +class InsertProgress: + phase: str + percent: int + current: int + total: int + label: str diff --git a/tests/test_kb_insert.py b/tests/test_kb_insert.py index 6cc9e40..3931954 100644 --- a/tests/test_kb_insert.py +++ b/tests/test_kb_insert.py @@ -101,13 +101,19 @@ def test_insert_same_title_updates_existing_page(monkeypatch, tmp_path: Path) -> def test_insert_multiple_files_runs_agent_sequentially(monkeypatch, tmp_path: Path) -> None: calls: list[list[str]] = [] + progress = [] _fake_agent(monkeypatch, calls) first = tmp_path / "alpha.md" second = tmp_path / "beta.md" first.write_text("# Alpha\n\nFirst details.", encoding="utf-8") second.write_text("# Beta\n\nSecond details.", encoding="utf-8") - result = insert_paths([first, second], _config(), base_dir=tmp_path) + result = insert_paths( + [first, second], + _config(), + base_dir=tmp_path, + on_progress=progress.append, + ) wiki = tmp_path / "workspace" / "kb" / "wiki" assert calls[0][0].endswith("_alpha.md") @@ -115,6 +121,12 @@ def test_insert_multiple_files_runs_agent_sequentially(monkeypatch, tmp_path: Pa assert (wiki / "pages" / "1-alpha.md").exists() assert (wiki / "pages" / "2-beta.md").exists() assert [change.path for change in result.added] == ["pages/1-alpha.md", "pages/2-beta.md"] + assert progress[0].percent == 1 + merge_percents = [event.percent for event in progress if event.phase == "merge"] + assert 50 in merge_percents + assert 99 in merge_percents + assert progress[-1].percent == 100 + assert progress[-1].phase == "done" def test_pdf_requires_mineru_when_disabled(tmp_path: Path) -> None: From 0bc9376fb2e5cf3a39ab00b2289b4eb37c12b332 Mon Sep 17 00:00:00 2001 From: 77one Date: Thu, 14 May 2026 11:27:39 +0800 Subject: [PATCH 07/44] fix: start insert progress at one percent --- src/heta/cli/insert.py | 4 +++- src/heta/kb/insert.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/heta/cli/insert.py b/src/heta/cli/insert.py index 256ecd5..147e951 100644 --- a/src/heta/cli/insert.py +++ b/src/heta/cli/insert.py @@ -60,7 +60,7 @@ def insert_command( try: with _insert_progress() as progress: - task_id = progress.add_task("preparing insert", total=100, completed=0) + task_id = progress.add_task("preparing files", total=100, completed=1) def on_progress(event: InsertProgress) -> None: progress.update(task_id, completed=event.percent, description=_progress_description(event)) @@ -147,6 +147,8 @@ def _insert_progress() -> Progress: def _progress_description(event: InsertProgress) -> str: + if event.phase == "prepare": + return event.label if event.phase == "merge": return f"merging {event.current}/{event.total} · {event.label}" if event.phase == "finalize": diff --git a/src/heta/kb/insert.py b/src/heta/kb/insert.py index 0122219..0a6b1f1 100644 --- a/src/heta/kb/insert.py +++ b/src/heta/kb/insert.py @@ -27,6 +27,7 @@ def insert_paths( enable_pdf_planning: bool = True, on_progress: Callable[[InsertProgress], None] | None = None, ) -> InsertResult: + _emit_progress(on_progress, "prepare", 1, 0, 0, "preparing files") files = collect_insert_files(targets, config) if not files: raise ValueError("No supported files found.") From e1ca69f00c40791f85d0a0ae06d4177d947e5fb7 Mon Sep 17 00:00:00 2001 From: 77one Date: Thu, 14 May 2026 13:59:30 +0800 Subject: [PATCH 08/44] feat: add insert planning toggle --- src/heta/cli/__init__.py | 2 + src/heta/cli/init.py | 4 +- src/heta/cli/insert.py | 6 +-- src/heta/cli/insert_planning.py | 81 +++++++++++++++++++++++++++++++++ src/heta/cli/status.py | 7 +++ src/heta/config/__init__.py | 4 +- src/heta/config/schema.py | 21 +++++++++ tests/test_config_io.py | 11 +++-- tests/test_kb_insert.py | 3 +- tests/test_memory_ingest.py | 3 +- tests/test_pdf_plan.py | 3 +- tests/test_query.py | 3 +- tests/test_status.py | 5 +- tests/test_vector_index.py | 4 +- 14 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 src/heta/cli/insert_planning.py diff --git a/src/heta/cli/__init__.py b/src/heta/cli/__init__.py index 3143ea8..601e707 100644 --- a/src/heta/cli/__init__.py +++ b/src/heta/cli/__init__.py @@ -8,6 +8,7 @@ from heta.cli import init as init_module from heta.cli.init import interactive_init from heta.cli.insert import insert_command +from heta.cli.insert_planning import app as insert_planning_app from heta.cli.query import query_command from heta.cli.recall import recall_command from heta.cli.remember import remember_command @@ -43,4 +44,5 @@ def init_command() -> None: app.command("remember")(remember_command) app.command("recall")(recall_command) app.command("status")(status_command) +app.add_typer(insert_planning_app) app.add_typer(vector_app) diff --git a/src/heta/cli/init.py b/src/heta/cli/init.py index f3db799..d31cfa7 100644 --- a/src/heta/cli/init.py +++ b/src/heta/cli/init.py @@ -16,7 +16,7 @@ from heta.cli.branding import APP_TAGLINE, brand_line from heta.config.io import CONFIG_PATH, save_config -from heta.config.schema import HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig +from heta.config.schema import HetaConfig, InsertPlanningConfig, LLMConfig, MinerUConfig, VectorIndexConfig from heta.providers.llm import validate_llm from heta.providers.mineru import validate_mineru_cloud, validate_mineru_local @@ -61,6 +61,7 @@ def _run_interactive_init() -> None: llm=llm_config, mineru=MinerUConfig.disabled(), vector_index=VectorIndexConfig.enabled(), + insert_planning=InsertPlanningConfig.enabled(), ) save_config(partial_config) console.print(f"[{HETA}]→[/] wrote {CONFIG_PATH}") @@ -71,6 +72,7 @@ def _run_interactive_init() -> None: llm=llm_config, mineru=mineru_config, vector_index=VectorIndexConfig.enabled(), + insert_planning=InsertPlanningConfig.enabled(), ) save_config(final_config) diff --git a/src/heta/cli/insert.py b/src/heta/cli/insert.py index 147e951..25d5721 100644 --- a/src/heta/cli/insert.py +++ b/src/heta/cli/insert.py @@ -30,11 +30,6 @@ def insert_command( None, help="File or directory paths to insert. Defaults to the current directory.", ), - pdf_planning: bool = typer.Option( - True, - "--pdf-planning/--no-pdf-planning", - help="Split large PDFs before parsing to avoid oversized agent context.", - ), ) -> None: """Insert files into the Little Heta Markdown knowledge base.""" config = load_config() @@ -56,6 +51,7 @@ def insert_command( console.print(f"[{MUTED}] Supported:[/] {extensions}") raise typer.Exit(1) + pdf_planning = config.insert_planning.enable _show_plan(files, config, pdf_planning=pdf_planning) try: diff --git a/src/heta/cli/insert_planning.py b/src/heta/cli/insert_planning.py new file mode 100644 index 0000000..60ebfc7 --- /dev/null +++ b/src/heta/cli/insert_planning.py @@ -0,0 +1,81 @@ +"""`heta insert-planning` commands.""" + +from __future__ import annotations + +from dataclasses import replace + +import typer +from rich.console import Console + +from heta.config.io import CONFIG_PATH, load_config, save_config +from heta.config.schema import InsertPlanningConfig + +console = Console() + +HETA = "rgb(52,144,220)" +MUTED = "rgb(126,146,158)" +OK = "rgb(76,196,142)" +WARN = "rgb(238,183,74)" + +app = typer.Typer( + name="insert-planning", + help="Manage Little Heta insert planning loops.", + no_args_is_help=True, + rich_markup_mode="rich", +) + + +@app.command("on") +def insert_planning_on() -> None: + """Enable insert planning loops such as large PDF split planning.""" + _set_insert_planning(True) + + +@app.command("true") +def insert_planning_true() -> None: + """Enable insert planning loops.""" + _set_insert_planning(True) + + +@app.command("off") +def insert_planning_off() -> None: + """Disable insert planning loops such as large PDF split planning.""" + _set_insert_planning(False) + + +@app.command("false") +def insert_planning_false() -> None: + """Disable insert planning loops.""" + _set_insert_planning(False) + + +@app.command("status") +def insert_planning_status() -> None: + """Show whether insert planning loops are enabled.""" + config = _require_config() + state = "enabled" if config.insert_planning.enable else "disabled" + console.print(f"[{MUTED}]insert planning:[/] [bold {HETA}]{state}[/]") + + +def _set_insert_planning(enable: bool) -> None: + config = _require_config() + updated = replace(config, insert_planning=InsertPlanningConfig(enable=enable)) + save_config(updated) + state = "enabled" if enable else "disabled" + console.print(f"[{OK}]✓[/] insert planning {state}") + + +def _require_config(): + try: + config = load_config() + except Exception as exc: + console.print(f"[{WARN}]?[/] Failed to read config: {exc}") + raise typer.Exit(1) from exc + if config is None: + console.print(f"[{WARN}]?[/] Little Heta is not initialized.") + console.print(f"[{MUTED}] Missing config:[/] {CONFIG_PATH}") + raise typer.Exit(1) + return config + + +__all__ = ["app"] diff --git a/src/heta/cli/status.py b/src/heta/cli/status.py index 14b91e1..10bb9fe 100644 --- a/src/heta/cli/status.py +++ b/src/heta/cli/status.py @@ -30,6 +30,7 @@ class StatusSummary: llm_provider: str mineru: str + insert_planning: str kb_files: int wiki_pages: int heta_space: Path @@ -55,6 +56,7 @@ def build_status_summary(config: HetaConfig | None, base_dir: Path | None = None return StatusSummary( llm_provider=config.llm.provider if config else "not configured", mineru=_mineru_summary(config.mineru) if config else "not configured", + insert_planning=_enabled_summary(config.insert_planning.enable) if config else "not configured", kb_files=_count_files(paths.raw_dir(base_dir)), wiki_pages=_count_markdown_pages(paths.pages_dir(base_dir)), heta_space=heta_space, @@ -70,6 +72,7 @@ def _show_status(summary: StatusSummary, has_config: bool) -> None: table.add_row("Heta space:", f"{_display_path(summary.heta_space).rstrip('/')}/") table.add_row("Model provider:", summary.llm_provider) table.add_row("MinerU:", summary.mineru) + table.add_row("Insert planning:", summary.insert_planning) table.add_row("KB files:", str(summary.kb_files)) table.add_row("Wiki pages:", str(summary.wiki_pages)) @@ -98,6 +101,10 @@ def _mineru_summary(config: MinerUConfig) -> str: return "cloud" +def _enabled_summary(enable: bool) -> str: + return "enabled" if enable else "disabled" + + def _status_content(table: Table) -> Table: layout = Table.grid() layout.add_column() diff --git a/src/heta/config/__init__.py b/src/heta/config/__init__.py index 0fbcb10..46d0d10 100644 --- a/src/heta/config/__init__.py +++ b/src/heta/config/__init__.py @@ -1,14 +1,14 @@ """Configuration helpers for Little Heta.""" from heta.config.io import CONFIG_PATH, load_config, save_config -from heta.config.schema import HetaConfig, LLMConfig, MinerUConfig +from heta.config.schema import HetaConfig, InsertPlanningConfig, LLMConfig, MinerUConfig __all__ = [ "CONFIG_PATH", "HetaConfig", + "InsertPlanningConfig", "LLMConfig", "MinerUConfig", "load_config", "save_config", ] - diff --git a/src/heta/config/schema.py b/src/heta/config/schema.py index 7a2f397..95cc420 100644 --- a/src/heta/config/schema.py +++ b/src/heta/config/schema.py @@ -73,12 +73,29 @@ def from_dict(cls, data: dict[str, Any]) -> "VectorIndexConfig": return cls(enable=enable) +@dataclass(frozen=True) +class InsertPlanningConfig: + enable: bool + + @classmethod + def enabled(cls) -> "InsertPlanningConfig": + return cls(enable=True) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "InsertPlanningConfig": + enable = data.get("enable") + if not isinstance(enable, bool): + raise ValueError("Invalid insert_planning enable flag in config.") + return cls(enable=enable) + + @dataclass(frozen=True) class HetaConfig: version: int llm: LLMConfig mineru: MinerUConfig vector_index: VectorIndexConfig + insert_planning: InsertPlanningConfig @classmethod def from_dict(cls, data: dict[str, Any]) -> "HetaConfig": @@ -88,17 +105,21 @@ def from_dict(cls, data: dict[str, Any]) -> "HetaConfig": llm = data.get("llm") mineru = data.get("mineru") vector_index = data.get("vector_index") + insert_planning = data.get("insert_planning") if not isinstance(llm, dict): raise ValueError("Missing LLM config.") if not isinstance(mineru, dict): raise ValueError("Missing MinerU config.") if not isinstance(vector_index, dict): raise ValueError("Missing vector_index config.") + if not isinstance(insert_planning, dict): + raise ValueError("Missing insert_planning config.") return cls( version=1, llm=LLMConfig.from_dict(llm), mineru=MinerUConfig.from_dict(mineru), vector_index=VectorIndexConfig.from_dict(vector_index), + insert_planning=InsertPlanningConfig.from_dict(insert_planning), ) def to_dict(self) -> dict[str, Any]: diff --git a/tests/test_config_io.py b/tests/test_config_io.py index 3fc8664..6fdb1cd 100644 --- a/tests/test_config_io.py +++ b/tests/test_config_io.py @@ -1,7 +1,7 @@ from pathlib import Path from heta.config.io import load_config, save_config -from heta.config.schema import HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig +from heta.config.schema import InsertPlanningConfig, HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig def test_save_and_load_config(tmp_path: Path) -> None: @@ -11,6 +11,7 @@ def test_save_and_load_config(tmp_path: Path) -> None: llm=LLMConfig(provider="qwen", api_key="sk-test"), mineru=MinerUConfig.disabled(), vector_index=VectorIndexConfig.enabled(), + insert_planning=InsertPlanningConfig.enabled(), ) save_config(config, path) @@ -24,7 +25,7 @@ def test_load_missing_config_returns_none(tmp_path: Path) -> None: assert load_config(tmp_path / "missing.yaml") is None -def test_config_requires_vector_index(tmp_path: Path) -> None: +def test_config_requires_insert_planning(tmp_path: Path) -> None: path = tmp_path / ".heta" / "heta.yaml" path.parent.mkdir(parents=True) path.write_text( @@ -38,6 +39,8 @@ def test_config_requires_vector_index(tmp_path: Path) -> None: provider: api_key: endpoint: +vector_index: + enable: true """, encoding="utf-8", ) @@ -45,6 +48,6 @@ def test_config_requires_vector_index(tmp_path: Path) -> None: try: load_config(path) except ValueError as exc: - assert "vector_index" in str(exc) + assert "insert_planning" in str(exc) else: - raise AssertionError("missing vector_index should fail") + raise AssertionError("missing insert_planning should fail") diff --git a/tests/test_kb_insert.py b/tests/test_kb_insert.py index 3931954..f402be7 100644 --- a/tests/test_kb_insert.py +++ b/tests/test_kb_insert.py @@ -2,7 +2,7 @@ import pytest -from heta.config.schema import HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig +from heta.config.schema import InsertPlanningConfig, HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig from heta.kb.discovery import collect_insert_files from heta.kb.models import FileChange from heta.kb.insert import insert_paths @@ -16,6 +16,7 @@ def _config(mineru: MinerUConfig | None = None) -> HetaConfig: llm=LLMConfig(provider="qwen", api_key="sk-test"), mineru=mineru or MinerUConfig.disabled(), vector_index=VectorIndexConfig(enable=False), + insert_planning=InsertPlanningConfig.enabled(), ) diff --git a/tests/test_memory_ingest.py b/tests/test_memory_ingest.py index c9bb51a..f59d066 100644 --- a/tests/test_memory_ingest.py +++ b/tests/test_memory_ingest.py @@ -12,7 +12,7 @@ import pytest -from heta.config.schema import HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig +from heta.config.schema import InsertPlanningConfig, HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig from heta.mem.client import EMBEDDING_DIM from heta.mem.db import get_connection, init_db from heta.mem.pipeline import remember @@ -53,6 +53,7 @@ def config() -> HetaConfig: llm=LLMConfig(provider="qwen", api_key="sk-test"), mineru=MinerUConfig.disabled(), vector_index=VectorIndexConfig.enabled(), + insert_planning=InsertPlanningConfig.enabled(), ) diff --git a/tests/test_pdf_plan.py b/tests/test_pdf_plan.py index 00cea48..0a76351 100644 --- a/tests/test_pdf_plan.py +++ b/tests/test_pdf_plan.py @@ -3,7 +3,7 @@ from pypdf import PdfWriter -from heta.config.schema import HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig +from heta.config.schema import InsertPlanningConfig, HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig from heta.kb import paths from heta.kb.pdf_plan import PDF_PAGE_THRESHOLD, SplitUnit, estimate_pdf_pages, plan_insert_files @@ -119,4 +119,5 @@ def _config() -> HetaConfig: llm=LLMConfig(provider="qwen", api_key="sk-test"), mineru=MinerUConfig.disabled(), vector_index=VectorIndexConfig(enable=False), + insert_planning=InsertPlanningConfig.enabled(), ) diff --git a/tests/test_query.py b/tests/test_query.py index a76eb16..d35de4d 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -2,7 +2,7 @@ import pytest -from heta.config.schema import HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig +from heta.config.schema import InsertPlanningConfig, HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig from heta.kb import paths from heta.kb.text import frontmatter_page from heta.query.models import QueryResult, QuerySource, VectorMatch @@ -16,6 +16,7 @@ def _config(vector_enabled: bool = False) -> HetaConfig: llm=LLMConfig(provider="qwen", api_key="sk-test"), mineru=MinerUConfig.disabled(), vector_index=VectorIndexConfig(enable=vector_enabled), + insert_planning=InsertPlanningConfig.enabled(), ) diff --git a/tests/test_status.py b/tests/test_status.py index 3d231dd..26ba977 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -1,7 +1,7 @@ from pathlib import Path from heta.cli.status import build_status_summary -from heta.config.schema import HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig +from heta.config.schema import InsertPlanningConfig, HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig def test_status_summary_counts_kb_and_wiki_pages(tmp_path: Path) -> None: @@ -19,12 +19,14 @@ def test_status_summary_counts_kb_and_wiki_pages(tmp_path: Path) -> None: llm=LLMConfig(provider="qwen", api_key="sk-test"), mineru=MinerUConfig(enable=True, provider="local", api_key=None, endpoint="http://127.0.0.1:8000"), vector_index=VectorIndexConfig.enabled(), + insert_planning=InsertPlanningConfig.enabled(), ) summary = build_status_summary(config, tmp_path) assert summary.llm_provider == "qwen" assert summary.mineru == "local (http://127.0.0.1:8000)" + assert summary.insert_planning == "enabled" assert summary.kb_files == 2 assert summary.wiki_pages == 1 assert summary.heta_space == tmp_path @@ -37,6 +39,7 @@ def test_status_summary_handles_missing_config_and_workspace(tmp_path: Path) -> assert summary.llm_provider == "not configured" assert summary.mineru == "not configured" + assert summary.insert_planning == "not configured" assert summary.kb_files == 0 assert summary.wiki_pages == 0 assert summary.heta_used_bytes == 0 diff --git a/tests/test_vector_index.py b/tests/test_vector_index.py index 0b3cc21..23c819d 100644 --- a/tests/test_vector_index.py +++ b/tests/test_vector_index.py @@ -5,7 +5,7 @@ import sqlite_vec -from heta.config.schema import HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig +from heta.config.schema import InsertPlanningConfig, HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig from heta.kb import paths from heta.kb.models import FileChange from heta.kb.vector_index import ( @@ -84,6 +84,7 @@ def test_search_wiki_vector_index_returns_ranked_chunks(monkeypatch, tmp_path: P llm=LLMConfig(provider="qwen", api_key="sk-test"), mineru=MinerUConfig.disabled(), vector_index=VectorIndexConfig.enabled(), + insert_planning=InsertPlanningConfig.enabled(), ) results = search_wiki_vector_index(query="table synthesis", config=config, top_k=3, base_dir=tmp_path) @@ -117,6 +118,7 @@ def test_sync_wiki_vector_index_deduplicates_repeated_page_changes(monkeypatch, llm=LLMConfig(provider="qwen", api_key="sk-test"), mineru=MinerUConfig.disabled(), vector_index=VectorIndexConfig.enabled(), + insert_planning=InsertPlanningConfig.enabled(), ) sync_wiki_vector_index( From 1bacb305ab29bf889f1dc0b471064370b237aabd Mon Sep 17 00:00:00 2001 From: 77one Date: Thu, 14 May 2026 15:11:59 +0800 Subject: [PATCH 09/44] feat: support image insert captions --- src/heta/kb/discovery.py | 4 +- src/heta/kb/image_parser.py | 162 ++++++++++++++++++++++++++++++++++++ src/heta/kb/parser.py | 3 + src/heta/kb/text.py | 8 ++ tests/test_image_parser.py | 63 ++++++++++++++ tests/test_kb_insert.py | 9 ++ 6 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 src/heta/kb/image_parser.py create mode 100644 tests/test_image_parser.py diff --git a/src/heta/kb/discovery.py b/src/heta/kb/discovery.py index e15a1c7..997e875 100644 --- a/src/heta/kb/discovery.py +++ b/src/heta/kb/discovery.py @@ -8,10 +8,11 @@ PLAIN_EXTENSIONS = {".md", ".markdown", ".txt"} MINERU_EXTENSIONS = {".pdf"} +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} def supported_extensions(config: HetaConfig) -> set[str]: - extensions = set(PLAIN_EXTENSIONS) + extensions = set(PLAIN_EXTENSIONS) | IMAGE_EXTENSIONS if config.mineru.enable: extensions |= MINERU_EXTENSIONS return extensions @@ -58,4 +59,3 @@ def _add_supported_file(files: list[Path], path: Path, extensions: set[str]) -> def _is_ignored_path(path: Path) -> bool: ignored = {".git", ".worktrees", "__pycache__", ".pytest_cache", "workspace"} return any(part in ignored for part in path.parts) - diff --git a/src/heta/kb/image_parser.py b/src/heta/kb/image_parser.py new file mode 100644 index 0000000..70bd703 --- /dev/null +++ b/src/heta/kb/image_parser.py @@ -0,0 +1,162 @@ +"""Image parsing for Little Heta KB inserts.""" + +from __future__ import annotations + +import base64 +import json +from datetime import date +from pathlib import Path +from typing import Any + +from heta.config.schema import HetaConfig +from heta.kb.agent import _chat_completion, _get_client + +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} + +_MIME_TYPES = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", +} + + +def parse_image_markdown(source_path: Path, archived_path: Path, config: HetaConfig) -> str: + """Describe an image with a VLM and return stable wiki-flavored Markdown.""" + description = describe_image(source_path=source_path, config=config) + return build_image_markdown( + title=f"Image - {source_path.stem}", + source_name=archived_path.name, + image_path=f"../../raw/{archived_path.name}", + summary=description["summary"], + visual_facts=description["visual_facts"], + visible_text=description["visible_text"], + interpretation_keywords=description["interpretation_keywords"], + ) + + +def describe_image(*, source_path: Path, config: HetaConfig) -> dict[str, str]: + client, model = _get_client(config) + response = _chat_completion( + client=client, + model=model, + messages=[ + {"role": "system", "content": _image_system_prompt()}, + { + "role": "user", + "content": [ + {"type": "text", "text": _image_user_prompt(source_path.name)}, + { + "type": "image_url", + "image_url": { + "url": _data_url(source_path), + }, + }, + ], + }, + ], + tools=None, + temperature=0.1, + config=config, + ) + raw = response.choices[0].message.content or "" + return _normalize_description(_extract_json_object(raw)) + + +def build_image_markdown( + *, + title: str, + source_name: str, + image_path: str, + summary: str, + visual_facts: str, + visible_text: str, + interpretation_keywords: str, +) -> str: + return ( + "---\n" + f"title: {title}\n" + f"sources: [{source_name}]\n" + f"updated: {date.today().isoformat()}\n" + "---\n\n" + "## Summary\n\n" + f"{summary.strip()}\n\n" + "## Content\n\n" + f"![{source_name}](<{image_path}>)\n\n" + "### Visual Facts\n\n" + f"{visual_facts.strip()}\n\n" + "### Visible Text\n\n" + f"{visible_text.strip() or 'None detected.'}\n\n" + "### Interpretation and Keywords\n\n" + f"{interpretation_keywords.strip()}\n\n" + "## Related Pages\n\n" + "- None yet\n\n" + "## Source\n\n" + f"- {source_name}\n" + ) + + +def _image_system_prompt() -> str: + return """You are an image-to-Markdown parser for Little Heta KB inserts. +Return only one valid JSON object. Do not wrap it in Markdown fences. +Be detailed, factual, and efficient. Do not invent hidden context. +If visible text exists, transcribe it faithfully. If there is no visible text, +write "None detected.".""" + + +def _image_user_prompt(filename: str) -> str: + return f"""Describe this image for semantic retrieval. + +Filename: {filename} + +Return JSON with exactly these string fields: +- summary: one concise paragraph describing what the image is and why it matters. +- visual_facts: detailed factual description of scene type, main subject, objects, people, layout, colors, labels, numbers, and spatial relations. +- visible_text: visible text transcription, or "None detected." +- interpretation_keywords: likely meaning or purpose with uncertainty if needed, ending with compact search keywords. +""" + + +def _data_url(path: Path) -> str: + suffix = path.suffix.lower() + mime = _MIME_TYPES.get(suffix) + if mime is None: + raise ValueError(f"Unsupported image type: {suffix}") + encoded = base64.b64encode(path.read_bytes()).decode("ascii") + return f"data:{mime};base64,{encoded}" + + +def _extract_json_object(text: str) -> dict[str, Any]: + stripped = text.strip() + if stripped.startswith("```"): + stripped = stripped.strip("`") + if stripped.lower().startswith("json"): + stripped = stripped[4:].strip() + try: + value = json.loads(stripped) + except json.JSONDecodeError: + start = stripped.find("{") + end = stripped.rfind("}") + if start == -1 or end == -1 or end <= start: + raise ValueError("Image model did not return JSON.") + value = json.loads(stripped[start : end + 1]) + if not isinstance(value, dict): + raise ValueError("Image model JSON must be an object.") + return value + + +def _normalize_description(data: dict[str, Any]) -> dict[str, str]: + fields = { + "summary": "Imported image.", + "visual_facts": "No visual facts extracted.", + "visible_text": "None detected.", + "interpretation_keywords": "Image; visual document.", + } + normalized: dict[str, str] = {} + for key, fallback in fields.items(): + value = data.get(key) + normalized[key] = str(value).strip() if value else fallback + return normalized + + +__all__ = ["IMAGE_EXTENSIONS", "build_image_markdown", "describe_image", "parse_image_markdown"] diff --git a/src/heta/kb/parser.py b/src/heta/kb/parser.py index c3d25a8..07734cc 100644 --- a/src/heta/kb/parser.py +++ b/src/heta/kb/parser.py @@ -8,6 +8,7 @@ import requests from heta.config.schema import HetaConfig +from heta.kb.image_parser import IMAGE_EXTENSIONS, parse_image_markdown from heta.kb.models import ParsedDocument from heta.kb.text import extract_title @@ -18,6 +19,8 @@ def parse_document(source_path: Path, archived_path: Path, config: HetaConfig) - markdown = source_path.read_text(encoding="utf-8") elif suffix == ".pdf": markdown = _parse_pdf_with_mineru(archived_path, config) + elif suffix in IMAGE_EXTENSIONS: + markdown = parse_image_markdown(source_path, archived_path, config) else: raise ValueError(f"Unsupported file type: {suffix}") diff --git a/src/heta/kb/text.py b/src/heta/kb/text.py index a4a183d..1b74613 100644 --- a/src/heta/kb/text.py +++ b/src/heta/kb/text.py @@ -14,8 +14,16 @@ def slugify(value: str) -> str: def extract_title(markdown: str, fallback: str) -> str: + in_frontmatter = False for line in markdown.splitlines(): stripped = line.strip() + if stripped == "---": + in_frontmatter = not in_frontmatter + continue + if in_frontmatter and stripped.startswith("title:"): + title = stripped.split(":", 1)[1].strip() + if title: + return title if stripped.startswith("#"): title = stripped.lstrip("#").strip() if title: diff --git a/tests/test_image_parser.py b/tests/test_image_parser.py new file mode 100644 index 0000000..45e5bd9 --- /dev/null +++ b/tests/test_image_parser.py @@ -0,0 +1,63 @@ +from pathlib import Path + +from heta.config.schema import HetaConfig, InsertPlanningConfig, LLMConfig, MinerUConfig, VectorIndexConfig +from heta.kb.image_parser import build_image_markdown +from heta.kb.parser import parse_document +from heta.kb.text import extract_title + + +def _config() -> HetaConfig: + return HetaConfig( + version=1, + llm=LLMConfig(provider="qwen", api_key="sk-test"), + mineru=MinerUConfig.disabled(), + vector_index=VectorIndexConfig(enable=False), + insert_planning=InsertPlanningConfig.enabled(), + ) + + +def test_build_image_markdown_uses_compact_retrieval_sections() -> None: + markdown = build_image_markdown( + title="Image - Architecture Diagram", + source_name="diagram.png", + image_path="../../raw/diagram.png", + summary="A system architecture diagram.", + visual_facts="Scene/type: diagram. Main subject: service pipeline.", + visible_text="API Gateway", + interpretation_keywords="Represents a backend data flow. keywords: API, pipeline.", + ) + + assert extract_title(markdown, "fallback") == "Image - Architecture Diagram" + assert "![diagram.png](<../../raw/diagram.png>)" in markdown + assert "### Visual Facts" in markdown + assert "### Visible Text" in markdown + assert "### Interpretation and Keywords" in markdown + assert "## Related Pages" in markdown + assert "## Source" in markdown + + +def test_parse_document_accepts_image_branch(monkeypatch, tmp_path: Path) -> None: + source = tmp_path / "diagram.png" + archived = tmp_path / "raw_diagram.png" + source.write_bytes(b"png") + archived.write_bytes(b"png") + + monkeypatch.setattr( + "heta.kb.parser.parse_image_markdown", + lambda source_path, archived_path, config: build_image_markdown( + title="Image - Diagram", + source_name=archived_path.name, + image_path="../../raw/raw_diagram.png", + summary="A diagram.", + visual_facts="A simple diagram.", + visible_text="None detected.", + interpretation_keywords="diagram, test", + ), + ) + + document = parse_document(source, archived, _config()) + + assert document.title == "Image - Diagram" + assert document.source_name == "raw_diagram.png" + assert document.metadata["extension"] == ".png" + assert "### Visual Facts" in document.markdown_content diff --git a/tests/test_kb_insert.py b/tests/test_kb_insert.py index f402be7..45b16bb 100644 --- a/tests/test_kb_insert.py +++ b/tests/test_kb_insert.py @@ -138,6 +138,15 @@ def test_pdf_requires_mineru_when_disabled(tmp_path: Path) -> None: collect_insert_files([source], _config()) +def test_collect_insert_files_accepts_common_images(tmp_path: Path) -> None: + image = tmp_path / "diagram.png" + image.write_bytes(b"png") + + files = collect_insert_files([image], _config()) + + assert files == [image] + + def test_collect_directory_skips_workspace(tmp_path: Path) -> None: source = tmp_path / "a.md" workspace_file = tmp_path / "workspace" / "kb" / "wiki" / "pages" / "old.md" From 79089356c2dcbb9419ad71b88fc59b6e88cdc115 Mon Sep 17 00:00:00 2001 From: 77one Date: Thu, 14 May 2026 19:19:31 +0800 Subject: [PATCH 10/44] feat: support audio insert transcripts --- src/heta/kb/audio_parser.py | 282 ++++++++++++++++++++++++++++++++++++ src/heta/kb/discovery.py | 3 +- src/heta/kb/parser.py | 3 + tests/test_audio_parser.py | 80 ++++++++++ tests/test_kb_insert.py | 11 ++ 5 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 src/heta/kb/audio_parser.py create mode 100644 tests/test_audio_parser.py diff --git a/src/heta/kb/audio_parser.py b/src/heta/kb/audio_parser.py new file mode 100644 index 0000000..1ac94cc --- /dev/null +++ b/src/heta/kb/audio_parser.py @@ -0,0 +1,282 @@ +"""Audio and video parsing for Little Heta KB inserts.""" + +from __future__ import annotations + +import base64 +import json +from datetime import date +from pathlib import Path +from typing import Any + +import requests +from openai import OpenAI + +from heta.config.schema import HetaConfig +from heta.kb.agent import _chat_completion, _get_client + +AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".webm", ".mp4"} + +QWEN_OMNI_MODEL = "qwen3.5-omni-flash" +OPENAI_TRANSCRIBE_MODEL = "gpt-4o-transcribe" +GEMINI_AUDIO_MODEL = "gemini-2.5-flash" + +_MIME_TYPES = { + ".mp3": "audio/mp3", + ".wav": "audio/wav", + ".m4a": "audio/mp4", + ".webm": "audio/webm", + ".mp4": "video/mp4", +} + +_QWEN_FORMATS = { + ".mp3": "mp3", + ".wav": "wav", + ".m4a": "m4a", + ".webm": "webm", +} + + +def parse_audio_markdown(source_path: Path, archived_path: Path, config: HetaConfig) -> str: + """Transcribe audio/video and return stable wiki-flavored Markdown.""" + description = transcribe_media(source_path=source_path, config=config) + media_kind = "Video" if source_path.suffix.lower() == ".mp4" else "Audio" + return build_audio_markdown( + title=f"{media_kind} - {source_path.stem}", + source_name=archived_path.name, + media_path=f"../../raw/{archived_path.name}", + media_kind=media_kind, + summary=description["summary"], + transcript=description["transcript"], + key_points_metadata=description["key_points_metadata"], + interpretation_keywords=description["interpretation_keywords"], + ) + + +def transcribe_media(*, source_path: Path, config: HetaConfig) -> dict[str, str]: + if config.llm.provider == "chatgpt": + transcript = _transcribe_with_openai(source_path, config) + return _structure_transcript(source_path=source_path, transcript=transcript, config=config) + if config.llm.provider == "qwen": + return _transcribe_with_qwen_omni(source_path, config) + if config.llm.provider == "gemini": + return _transcribe_with_gemini(source_path, config) + raise ValueError(f"Unsupported audio provider: {config.llm.provider}") + + +def build_audio_markdown( + *, + title: str, + source_name: str, + media_path: str, + media_kind: str, + summary: str, + transcript: str, + key_points_metadata: str, + interpretation_keywords: str, +) -> str: + link_label = f"{media_kind} file" + return ( + "---\n" + f"title: {title}\n" + f"sources: [{source_name}]\n" + f"updated: {date.today().isoformat()}\n" + "---\n\n" + "## Summary\n\n" + f"{summary.strip()}\n\n" + "## Content\n\n" + f"[{link_label}](<{media_path}>)\n\n" + "### Transcript\n\n" + f"{transcript.strip() or 'No transcript extracted.'}\n\n" + "### Key Points and Metadata\n\n" + f"{key_points_metadata.strip()}\n\n" + "### Interpretation and Keywords\n\n" + f"{interpretation_keywords.strip()}\n\n" + "## Related Pages\n\n" + "- None yet\n\n" + "## Source\n\n" + f"- {source_name}\n" + ) + + +def _transcribe_with_openai(path: Path, config: HetaConfig) -> str: + client = OpenAI(api_key=config.llm.api_key, timeout=300) + with path.open("rb") as file: + result = client.audio.transcriptions.create( + model=OPENAI_TRANSCRIBE_MODEL, + file=file, + response_format="text", + ) + return str(result).strip() + + +def _structure_transcript(*, source_path: Path, transcript: str, config: HetaConfig) -> dict[str, str]: + client, model = _get_client(config) + response = _chat_completion( + client=client, + model=model, + messages=[ + {"role": "system", "content": _media_json_system_prompt()}, + { + "role": "user", + "content": _structure_user_prompt(filename=source_path.name, transcript=transcript), + }, + ], + tools=None, + temperature=0.1, + config=config, + ) + raw = response.choices[0].message.content or "" + data = _normalize_description(_extract_json_object(raw)) + if not data["transcript"].strip(): + data["transcript"] = transcript + return data + + +def _transcribe_with_qwen_omni(path: Path, config: HetaConfig) -> dict[str, str]: + client = OpenAI( + api_key=config.llm.api_key, + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", + timeout=300, + ) + suffix = path.suffix.lower() + content: list[dict[str, Any]] = [{"type": "text", "text": _media_prompt(path.name)}] + if suffix == ".mp4": + content.append({"type": "video_url", "video_url": {"url": _data_url(path)}}) + else: + audio_format = _QWEN_FORMATS.get(suffix) + if audio_format is None: + raise ValueError(f"Unsupported Qwen audio type: {suffix}") + content.append( + { + "type": "input_audio", + "input_audio": { + "data": _data_url(path), + "format": audio_format, + }, + } + ) + + response = client.chat.completions.create( + model=QWEN_OMNI_MODEL, + messages=[{"role": "user", "content": content}], + temperature=0.1, + extra_body={"enable_thinking": False}, + ) + raw = response.choices[0].message.content or "" + return _normalize_description(_extract_json_object(raw)) + + +def _transcribe_with_gemini(path: Path, config: HetaConfig) -> dict[str, str]: + mime = _mime_type(path) + payload = { + "contents": [ + { + "role": "user", + "parts": [ + {"text": _media_prompt(path.name)}, + { + "inline_data": { + "mime_type": mime, + "data": base64.b64encode(path.read_bytes()).decode("ascii"), + } + }, + ], + } + ], + "generationConfig": {"temperature": 0.1}, + } + response = requests.post( + f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_AUDIO_MODEL}:generateContent", + params={"key": config.llm.api_key}, + json=payload, + timeout=300, + ) + if response.status_code != 200: + raise RuntimeError(f"Gemini audio transcription failed: HTTP {response.status_code} {response.text[:300]}") + parts = response.json().get("candidates", [{}])[0].get("content", {}).get("parts", []) + raw = "\n".join(str(part.get("text", "")) for part in parts).strip() + return _normalize_description(_extract_json_object(raw)) + + +def _media_json_system_prompt() -> str: + return """You are an audio/video-to-Markdown parser for Little Heta KB inserts. +Return only one valid JSON object. Do not wrap it in Markdown fences. +Keep the transcript faithful. Do not invent details not present in the transcript or media.""" + + +def _media_prompt(filename: str) -> str: + return f"""Transcribe and describe this audio/video for semantic retrieval. + +Filename: {filename} + +Return JSON with exactly these string fields: +- summary: one concise paragraph describing the media content. +- transcript: full transcript. Preserve speaker labels and timestamps if available. +- key_points_metadata: important facts, decisions, tasks, names, dates, places, speaker count, duration, language, and media type. +- interpretation_keywords: likely meaning or purpose with uncertainty if needed, ending with compact search keywords. +""" + + +def _structure_user_prompt(*, filename: str, transcript: str) -> str: + return f"""Structure this transcript for semantic retrieval. + +Filename: {filename} + +Transcript: +{transcript} + +Return JSON with exactly these string fields: +- summary +- transcript +- key_points_metadata +- interpretation_keywords +""" + + +def _data_url(path: Path) -> str: + encoded = base64.b64encode(path.read_bytes()).decode("ascii") + return f"data:{_mime_type(path)};base64,{encoded}" + + +def _mime_type(path: Path) -> str: + suffix = path.suffix.lower() + mime = _MIME_TYPES.get(suffix) + if mime is None: + raise ValueError(f"Unsupported audio/video type: {suffix}") + return mime + + +def _extract_json_object(text: str) -> dict[str, Any]: + stripped = text.strip() + if stripped.startswith("```"): + stripped = stripped.strip("`") + if stripped.lower().startswith("json"): + stripped = stripped[4:].strip() + try: + value = json.loads(stripped) + except json.JSONDecodeError: + start = stripped.find("{") + end = stripped.rfind("}") + if start == -1 or end == -1 or end <= start: + raise ValueError("Audio model did not return JSON.") + value = json.loads(stripped[start : end + 1]) + if not isinstance(value, dict): + raise ValueError("Audio model JSON must be an object.") + return value + + +def _normalize_description(data: dict[str, Any]) -> dict[str, str]: + fields = { + "summary": "Imported audio or video.", + "transcript": "No transcript extracted.", + "key_points_metadata": "No key points extracted.", + "interpretation_keywords": "Audio or video media; transcript.", + } + normalized: dict[str, str] = {} + for key, fallback in fields.items(): + value = data.get(key) + normalized[key] = str(value).strip() if value else fallback + return normalized + + +__all__ = ["AUDIO_EXTENSIONS", "build_audio_markdown", "parse_audio_markdown", "transcribe_media"] diff --git a/src/heta/kb/discovery.py b/src/heta/kb/discovery.py index 997e875..7a1f8d8 100644 --- a/src/heta/kb/discovery.py +++ b/src/heta/kb/discovery.py @@ -9,10 +9,11 @@ PLAIN_EXTENSIONS = {".md", ".markdown", ".txt"} MINERU_EXTENSIONS = {".pdf"} IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} +AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".webm", ".mp4"} def supported_extensions(config: HetaConfig) -> set[str]: - extensions = set(PLAIN_EXTENSIONS) | IMAGE_EXTENSIONS + extensions = set(PLAIN_EXTENSIONS) | IMAGE_EXTENSIONS | AUDIO_EXTENSIONS if config.mineru.enable: extensions |= MINERU_EXTENSIONS return extensions diff --git a/src/heta/kb/parser.py b/src/heta/kb/parser.py index 07734cc..9f6a9af 100644 --- a/src/heta/kb/parser.py +++ b/src/heta/kb/parser.py @@ -8,6 +8,7 @@ import requests from heta.config.schema import HetaConfig +from heta.kb.audio_parser import AUDIO_EXTENSIONS, parse_audio_markdown from heta.kb.image_parser import IMAGE_EXTENSIONS, parse_image_markdown from heta.kb.models import ParsedDocument from heta.kb.text import extract_title @@ -21,6 +22,8 @@ def parse_document(source_path: Path, archived_path: Path, config: HetaConfig) - markdown = _parse_pdf_with_mineru(archived_path, config) elif suffix in IMAGE_EXTENSIONS: markdown = parse_image_markdown(source_path, archived_path, config) + elif suffix in AUDIO_EXTENSIONS: + markdown = parse_audio_markdown(source_path, archived_path, config) else: raise ValueError(f"Unsupported file type: {suffix}") diff --git a/tests/test_audio_parser.py b/tests/test_audio_parser.py new file mode 100644 index 0000000..75f712d --- /dev/null +++ b/tests/test_audio_parser.py @@ -0,0 +1,80 @@ +from pathlib import Path + +from heta.config.schema import HetaConfig, InsertPlanningConfig, LLMConfig, MinerUConfig, VectorIndexConfig +from heta.kb.audio_parser import build_audio_markdown +from heta.kb.parser import parse_document +from heta.kb.text import extract_title + + +def _config() -> HetaConfig: + return HetaConfig( + version=1, + llm=LLMConfig(provider="qwen", api_key="sk-test"), + mineru=MinerUConfig.disabled(), + vector_index=VectorIndexConfig(enable=False), + insert_planning=InsertPlanningConfig.enabled(), + ) + + +def test_build_audio_markdown_uses_compact_retrieval_sections() -> None: + markdown = build_audio_markdown( + title="Audio - Meeting", + source_name="meeting.mp3", + media_path="../../raw/meeting.mp3", + media_kind="Audio", + summary="A meeting recording.", + transcript="Speaker 1: Let's ship the feature.", + key_points_metadata="Decision: ship the feature. Language: English.", + interpretation_keywords="Meeting notes. keywords: feature, release.", + ) + + assert extract_title(markdown, "fallback") == "Audio - Meeting" + assert "[Audio file](<../../raw/meeting.mp3>)" in markdown + assert "### Transcript" in markdown + assert "### Key Points and Metadata" in markdown + assert "### Interpretation and Keywords" in markdown + assert "## Related Pages" in markdown + assert "## Source" in markdown + + +def test_build_audio_markdown_supports_video_link_label() -> None: + markdown = build_audio_markdown( + title="Video - Demo", + source_name="demo.mp4", + media_path="../../raw/demo.mp4", + media_kind="Video", + summary="A product demo.", + transcript="Narrator: This is the dashboard.", + key_points_metadata="Media type: video.", + interpretation_keywords="Product demo, dashboard.", + ) + + assert "[Video file](<../../raw/demo.mp4>)" in markdown + + +def test_parse_document_accepts_audio_branch(monkeypatch, tmp_path: Path) -> None: + source = tmp_path / "meeting.mp3" + archived = tmp_path / "raw_meeting.mp3" + source.write_bytes(b"mp3") + archived.write_bytes(b"mp3") + + monkeypatch.setattr( + "heta.kb.parser.parse_audio_markdown", + lambda source_path, archived_path, config: build_audio_markdown( + title="Audio - Meeting", + source_name=archived_path.name, + media_path="../../raw/raw_meeting.mp3", + media_kind="Audio", + summary="A meeting.", + transcript="Speaker 1: hello.", + key_points_metadata="Language: English.", + interpretation_keywords="meeting, test", + ), + ) + + document = parse_document(source, archived, _config()) + + assert document.title == "Audio - Meeting" + assert document.source_name == "raw_meeting.mp3" + assert document.metadata["extension"] == ".mp3" + assert "### Transcript" in document.markdown_content diff --git a/tests/test_kb_insert.py b/tests/test_kb_insert.py index 45b16bb..cc6595a 100644 --- a/tests/test_kb_insert.py +++ b/tests/test_kb_insert.py @@ -147,6 +147,17 @@ def test_collect_insert_files_accepts_common_images(tmp_path: Path) -> None: assert files == [image] +def test_collect_insert_files_accepts_audio_and_video(tmp_path: Path) -> None: + audio = tmp_path / "meeting.mp3" + video = tmp_path / "demo.mp4" + audio.write_bytes(b"mp3") + video.write_bytes(b"mp4") + + files = collect_insert_files([audio, video], _config()) + + assert files == [audio, video] + + def test_collect_directory_skips_workspace(tmp_path: Path) -> None: source = tmp_path / "a.md" workspace_file = tmp_path / "workspace" / "kb" / "wiki" / "pages" / "old.md" From 0e93963e336127987f579a8d65abece15ddb4622 Mon Sep 17 00:00:00 2001 From: 77one Date: Thu, 14 May 2026 20:01:01 +0800 Subject: [PATCH 11/44] fix: tighten query source attribution --- src/heta/query/agent.py | 41 +++++++++++++++++++++++++++++++---------- tests/test_query.py | 15 +++++++++++++++ 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/heta/query/agent.py b/src/heta/query/agent.py index df6a80d..cd7d45e 100644 --- a/src/heta/query/agent.py +++ b/src/heta/query/agent.py @@ -66,6 +66,7 @@ def run_query_agent( stats = AgentStats(task_id="query", max_steps=max_steps, max_seconds=max_seconds) index_text = read_index(base_dir) initial_matches = search_vector(question, config, top_k=top_k, base_dir=base_dir) + primary_match_path = initial_matches[0].path if initial_matches else None messages: list[dict[str, Any]] = [ { "role": "user", @@ -78,7 +79,6 @@ def run_query_agent( } ] read_paths: set[str] = set() - vector_sources: dict[str, VectorMatch] = {match.path: match for match in initial_matches} tools = QUERY_TOOLS if config.vector_index.enable else [QUERY_TOOLS[0]] while stats.should_continue(): @@ -94,10 +94,35 @@ def run_query_agent( tool_calls = list(message.tool_calls or []) if not tool_calls: + if primary_match_path and not read_paths: + page_text = read_page(primary_match_path, base_dir) + if page_text.startswith("error:"): + primary_match_path = None + messages.append( + { + "role": "user", + "content": f"The primary semantic match could not be read: {page_text}", + } + ) + stats.record("read_page primary semantic match failed", response.usage) + continue + read_paths.add(primary_match_path) + messages.append( + { + "role": "user", + "content": ( + "Inspected the most relevant semantic match page so the final answer can cite " + f"evidence.\n\nPath: {primary_match_path}\n\nContent:\n{page_text}\n\n" + "Now answer the original question using inspected evidence." + ), + } + ) + stats.record("read_page primary semantic match", response.usage) + continue stats.record_completion(response.usage) return QueryResult( answer=message.content or "", - sources=_build_sources(read_paths=read_paths, vector_sources=vector_sources, base_dir=base_dir), + sources=_build_sources(read_paths=read_paths, base_dir=base_dir), usage=stats.finish("completed"), ) @@ -116,7 +141,7 @@ def run_query_agent( for tool_call in tool_calls ] messages.append(assistant_message) - messages.extend(_execute_tools(tool_calls, config, base_dir, top_k, read_paths, vector_sources)) + messages.extend(_execute_tools(tool_calls, config, base_dir, top_k, read_paths)) stats.record(", ".join(tool.function.name for tool in tool_calls), response.usage) messages.append( @@ -136,7 +161,7 @@ def run_query_agent( stats.record_completion(final.usage) return QueryResult( answer=final.choices[0].message.content or "", - sources=_build_sources(read_paths=read_paths, vector_sources=vector_sources, base_dir=base_dir), + sources=_build_sources(read_paths=read_paths, base_dir=base_dir), usage=stats.finish("stopped at limit"), ) @@ -156,6 +181,8 @@ def _system_prompt(vector_enabled: bool) -> str: - Treat index.md as the global map of pages, ids, paths, and summaries. - Treat semantic matches as starting evidence, not final truth. - If a chunk is relevant but incomplete, call read_page(path) for the full page. +- If you use a semantic match as evidence in the final answer, call read_page(path) + first so the CLI can cite only pages you actually inspected. - Follow useful [[Wiki Links]] by reading the linked pages when the index gives their paths. {vector_rule} - Stop reading when the context is enough. @@ -190,7 +217,6 @@ def _execute_tools( base_dir: Path | None, default_top_k: int, read_paths: set[str], - vector_sources: dict[str, VectorMatch], ) -> list[dict[str, Any]]: results: list[dict[str, Any]] = [] for tool_call in tool_calls: @@ -209,8 +235,6 @@ def _execute_tools( query = str(arguments.get("query", "")) top_k = int(arguments.get("top_k") or default_top_k) matches = search_vector(query, config, top_k=top_k, base_dir=base_dir) - for match in matches: - vector_sources.setdefault(match.path, match) output = format_vector_matches(matches) else: output = f"error: unknown tool {name}" @@ -221,12 +245,9 @@ def _execute_tools( def _build_sources( *, read_paths: set[str], - vector_sources: dict[str, VectorMatch], base_dir: Path | None, ) -> list[QuerySource]: sources: dict[str, QuerySource] = {} - for path, match in vector_sources.items(): - sources[path] = source_from_page_path(path, base_dir, heading_path=match.heading_path) for path in sorted(read_paths): sources[path] = source_from_page_path(path, base_dir) return list(sources.values()) diff --git a/tests/test_query.py b/tests/test_query.py index d35de4d..0c913e4 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -5,6 +5,7 @@ from heta.config.schema import InsertPlanningConfig, HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig from heta.kb import paths from heta.kb.text import frontmatter_page +from heta.query.agent import _build_sources from heta.query.models import QueryResult, QuerySource, VectorMatch from heta.query.pipeline import run_wiki_query from heta.query.tools import format_vector_matches, read_page, source_from_page_path @@ -62,6 +63,20 @@ def test_source_from_page_path_reads_frontmatter_and_wiki_id(tmp_path: Path) -> assert source == QuerySource(12, "HetaGen", "pages/12-hetagen.md", "Content") +def test_query_sources_only_include_pages_read_by_agent(tmp_path: Path) -> None: + pages = paths.pages_dir(tmp_path) + pages.mkdir(parents=True) + (pages / "8-image.md").write_text(frontmatter_page("Image", "image.png", "Image summary.", "Body."), encoding="utf-8") + (pages / "10-audio.md").write_text( + frontmatter_page("Audio", "audio.mp3", "Audio summary.", "Transcript."), + encoding="utf-8", + ) + + sources = _build_sources(read_paths={"pages/10-audio.md"}, base_dir=tmp_path) + + assert sources == [QuerySource(10, "Audio", "pages/10-audio.md")] + + def test_format_vector_matches_includes_chunk_identity() -> None: text = format_vector_matches( [ From 55ed99a8bb9870db2b56b7c321e6237b7e5d4c40 Mon Sep 17 00:00:00 2001 From: 77one Date: Thu, 14 May 2026 20:08:00 +0800 Subject: [PATCH 12/44] fix: validate query used sources --- src/heta/query/agent.py | 142 ++++++++++++++++++++++++++++------------ tests/test_query.py | 43 ++++++++++-- 2 files changed, 140 insertions(+), 45 deletions(-) diff --git a/src/heta/query/agent.py b/src/heta/query/agent.py index cd7d45e..bf96796 100644 --- a/src/heta/query/agent.py +++ b/src/heta/query/agent.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import re +from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any @@ -51,6 +53,12 @@ ] +@dataclass(frozen=True) +class FinalAnswer: + answer: str + sources: list[QuerySource] + + def run_query_agent( *, question: str, @@ -66,7 +74,7 @@ def run_query_agent( stats = AgentStats(task_id="query", max_steps=max_steps, max_seconds=max_seconds) index_text = read_index(base_dir) initial_matches = search_vector(question, config, top_k=top_k, base_dir=base_dir) - primary_match_path = initial_matches[0].path if initial_matches else None + vector_matches = _vector_match_map(initial_matches) messages: list[dict[str, Any]] = [ { "role": "user", @@ -94,35 +102,16 @@ def run_query_agent( tool_calls = list(message.tool_calls or []) if not tool_calls: - if primary_match_path and not read_paths: - page_text = read_page(primary_match_path, base_dir) - if page_text.startswith("error:"): - primary_match_path = None - messages.append( - { - "role": "user", - "content": f"The primary semantic match could not be read: {page_text}", - } - ) - stats.record("read_page primary semantic match failed", response.usage) - continue - read_paths.add(primary_match_path) - messages.append( - { - "role": "user", - "content": ( - "Inspected the most relevant semantic match page so the final answer can cite " - f"evidence.\n\nPath: {primary_match_path}\n\nContent:\n{page_text}\n\n" - "Now answer the original question using inspected evidence." - ), - } - ) - stats.record("read_page primary semantic match", response.usage) - continue + final_answer = _parse_final_answer( + text=message.content or "", + read_paths=read_paths, + vector_matches=vector_matches, + base_dir=base_dir, + ) stats.record_completion(response.usage) return QueryResult( - answer=message.content or "", - sources=_build_sources(read_paths=read_paths, base_dir=base_dir), + answer=final_answer.answer, + sources=final_answer.sources, usage=stats.finish("completed"), ) @@ -141,7 +130,7 @@ def run_query_agent( for tool_call in tool_calls ] messages.append(assistant_message) - messages.extend(_execute_tools(tool_calls, config, base_dir, top_k, read_paths)) + messages.extend(_execute_tools(tool_calls, config, base_dir, top_k, read_paths, vector_matches)) stats.record(", ".join(tool.function.name for tool in tool_calls), response.usage) messages.append( @@ -159,9 +148,15 @@ def run_query_agent( config=config, ) stats.record_completion(final.usage) + final_answer = _parse_final_answer( + text=final.choices[0].message.content or "", + read_paths=read_paths, + vector_matches=vector_matches, + base_dir=base_dir, + ) return QueryResult( - answer=final.choices[0].message.content or "", - sources=_build_sources(read_paths=read_paths, base_dir=base_dir), + answer=final_answer.answer, + sources=final_answer.sources, usage=stats.finish("stopped at limit"), ) @@ -181,15 +176,16 @@ def _system_prompt(vector_enabled: bool) -> str: - Treat index.md as the global map of pages, ids, paths, and summaries. - Treat semantic matches as starting evidence, not final truth. - If a chunk is relevant but incomplete, call read_page(path) for the full page. -- If you use a semantic match as evidence in the final answer, call read_page(path) - first so the CLI can cite only pages you actually inspected. - Follow useful [[Wiki Links]] by reading the linked pages when the index gives their paths. {vector_rule} - Stop reading when the context is enough. - If the wiki does not contain enough evidence, say what is missing. -- Answer directly in Markdown. -- Do not include a Sources, References, or Citations section in the answer body. - The CLI renders sources separately from tool usage. +- Your final response must be exactly one valid JSON object, with no Markdown fence: + {{"answer": "Markdown answer text", "used_sources": [{{"path": "pages/example.md", "heading_path": "Content > Section"}}]}} +- In used_sources, include only evidence you actually used. You may cite a semantic + match directly when its chunk is sufficient; use its exact path and heading. +- Do not include a Sources, References, or Citations section in answer. + The CLI renders validated sources separately. """ @@ -217,6 +213,7 @@ def _execute_tools( base_dir: Path | None, default_top_k: int, read_paths: set[str], + vector_matches: dict[tuple[str, str], VectorMatch], ) -> list[dict[str, Any]]: results: list[dict[str, Any]] = [] for tool_call in tool_calls: @@ -235,6 +232,7 @@ def _execute_tools( query = str(arguments.get("query", "")) top_k = int(arguments.get("top_k") or default_top_k) matches = search_vector(query, config, top_k=top_k, base_dir=base_dir) + vector_matches.update(_vector_match_map(matches)) output = format_vector_matches(matches) else: output = f"error: unknown tool {name}" @@ -242,12 +240,74 @@ def _execute_tools( return results -def _build_sources( +def _vector_match_map(matches: list[VectorMatch]) -> dict[tuple[str, str], VectorMatch]: + return {(_normalize_candidate_path(match.path), match.heading_path): match for match in matches} + + +def _parse_final_answer( *, + text: str, read_paths: set[str], + vector_matches: dict[tuple[str, str], VectorMatch], base_dir: Path | None, -) -> list[QuerySource]: +) -> FinalAnswer: + data = _extract_json_object(text) + if data is None: + return FinalAnswer(answer=text, sources=[]) + + answer = data.get("answer") + used_sources = data.get("used_sources") + if not isinstance(answer, str): + answer = text + if not isinstance(used_sources, list): + used_sources = [] + sources: dict[str, QuerySource] = {} - for path in sorted(read_paths): - sources[path] = source_from_page_path(path, base_dir) - return list(sources.values()) + normalized_read_paths = {_normalize_candidate_path(path) for path in read_paths} + for source in used_sources: + if not isinstance(source, dict): + continue + raw_path = source.get("path") + if not isinstance(raw_path, str): + continue + try: + path = _normalize_candidate_path(raw_path) + except ValueError: + continue + heading = source.get("heading_path") + heading_path = str(heading).strip() if heading else "" + key = (path, heading_path) + + if path in normalized_read_paths: + display_heading = heading_path or None + elif key in vector_matches: + display_heading = heading_path + else: + continue + sources[f"{path}#{display_heading or ''}"] = source_from_page_path(path, base_dir, heading_path=display_heading) + return FinalAnswer(answer=answer, sources=list(sources.values())) + + +def _normalize_candidate_path(path: str) -> str: + return path.replace("\\", "/").strip("/") + + +def _extract_json_object(text: str) -> dict[str, Any] | None: + stripped = text.strip() + if not stripped: + return None + if stripped.startswith("```"): + stripped = stripped.strip("`") + if stripped.lower().startswith("json"): + stripped = stripped[4:].strip() + try: + value = json.loads(stripped) + except json.JSONDecodeError: + match = re.search(r"\{.*\}", stripped, flags=re.DOTALL) + if match is None: + return None + try: + value = json.loads(match.group(0)) + except json.JSONDecodeError: + return None + return value if isinstance(value, dict) else None diff --git a/tests/test_query.py b/tests/test_query.py index 0c913e4..1136715 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -5,7 +5,7 @@ from heta.config.schema import InsertPlanningConfig, HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig from heta.kb import paths from heta.kb.text import frontmatter_page -from heta.query.agent import _build_sources +from heta.query.agent import _parse_final_answer, _vector_match_map from heta.query.models import QueryResult, QuerySource, VectorMatch from heta.query.pipeline import run_wiki_query from heta.query.tools import format_vector_matches, read_page, source_from_page_path @@ -63,7 +63,7 @@ def test_source_from_page_path_reads_frontmatter_and_wiki_id(tmp_path: Path) -> assert source == QuerySource(12, "HetaGen", "pages/12-hetagen.md", "Content") -def test_query_sources_only_include_pages_read_by_agent(tmp_path: Path) -> None: +def test_query_sources_include_validated_vector_chunks_only(tmp_path: Path) -> None: pages = paths.pages_dir(tmp_path) pages.mkdir(parents=True) (pages / "8-image.md").write_text(frontmatter_page("Image", "image.png", "Image summary.", "Body."), encoding="utf-8") @@ -71,10 +71,45 @@ def test_query_sources_only_include_pages_read_by_agent(tmp_path: Path) -> None: frontmatter_page("Audio", "audio.mp3", "Audio summary.", "Transcript."), encoding="utf-8", ) + vector_matches = _vector_match_map( + [ + VectorMatch(8, "8-image.md", "pages/8-image.md", "8:abc", "Content > Visible Text", "image text", 0.8), + VectorMatch(10, "10-audio.md", "pages/10-audio.md", "10:def", "Content > Transcript", "hello", 0.9), + ] + ) + + final = _parse_final_answer( + text=( + '{"answer": "The audio says hello.", "used_sources": [' + '{"path": "pages/10-audio.md", "heading_path": "Content > Transcript"},' + '{"path": "pages/8-image.md", "heading_path": "Content > Missing"}' + "]}" + ), + read_paths=set(), + vector_matches=vector_matches, + base_dir=tmp_path, + ) + + assert final.answer == "The audio says hello." + assert final.sources == [QuerySource(10, "Audio", "pages/10-audio.md", "Content > Transcript")] - sources = _build_sources(read_paths={"pages/10-audio.md"}, base_dir=tmp_path) - assert sources == [QuerySource(10, "Audio", "pages/10-audio.md")] +def test_query_sources_accept_read_pages_without_vector_heading(tmp_path: Path) -> None: + pages = paths.pages_dir(tmp_path) + pages.mkdir(parents=True) + (pages / "10-audio.md").write_text( + frontmatter_page("Audio", "audio.mp3", "Audio summary.", "Transcript."), + encoding="utf-8", + ) + + final = _parse_final_answer( + text='{"answer": "From the full page.", "used_sources": [{"path": "pages/10-audio.md"}]}', + read_paths={"pages/10-audio.md"}, + vector_matches={}, + base_dir=tmp_path, + ) + + assert final.sources == [QuerySource(10, "Audio", "pages/10-audio.md")] def test_format_vector_matches_includes_chunk_identity() -> None: From 1eae8612e1c5ca26792f2cd23adbd87244055f09 Mon Sep 17 00:00:00 2001 From: wangwenwu Date: Thu, 14 May 2026 20:34:27 +0800 Subject: [PATCH 13/44] feat: add ask command with outer agent loop over memory and KB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `heta ask`: an outer agent that decides between two tools — recall_memory (fast multi-layer memory retrieval) and query_kb (deep wiki sub-agent) — and synthesises a final answer. Memory layers include raw turns (FTS5), episodes, atomic facts, and a new kb_insight layer that caches distilled knowledge points from KB pages. Adds `heta mem-clean` to wipe memory data while preserving schema. --- pyproject.toml | 5 + src/heta/cli/__init__.py | 4 + src/heta/cli/ask.py | 82 ++++ src/heta/cli/mem_clean.py | 39 ++ src/heta/mem/clean.py | 23 +- src/heta/mem/db.py | 57 +++ src/heta/mem/kb_store.py | 54 +++ src/heta/mem/kb_writer.py | 123 +++++ src/heta/mem/l0_search.py | 14 +- src/heta/mem/models.py | 11 + src/heta/mem/prompts.py | 66 ++- src/heta/mem/recall.py | 146 ++++-- src/heta/query/pipeline.py | 2 - src/heta/query/smart_query.py | 260 +++++++++++ uv.lock | 850 ++++++++++++++++++++++++++++++++++ 15 files changed, 1683 insertions(+), 53 deletions(-) create mode 100644 src/heta/cli/ask.py create mode 100644 src/heta/cli/mem_clean.py create mode 100644 src/heta/mem/kb_store.py create mode 100644 src/heta/mem/kb_writer.py create mode 100644 src/heta/query/smart_query.py create mode 100644 uv.lock diff --git a/pyproject.toml b/pyproject.toml index 63cb7f0..1bd7cb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,3 +33,8 @@ where = ["src"] [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] + +[dependency-groups] +dev = [ + "pytest>=9.0.3", +] diff --git a/src/heta/cli/__init__.py b/src/heta/cli/__init__.py index 601e707..3c633f8 100644 --- a/src/heta/cli/__init__.py +++ b/src/heta/cli/__init__.py @@ -4,7 +4,9 @@ import typer +from heta.cli.ask import ask_command from heta.cli.clean import clean_command +from heta.cli.mem_clean import mem_clean_command from heta.cli import init as init_module from heta.cli.init import interactive_init from heta.cli.insert import insert_command @@ -38,6 +40,8 @@ def init_command() -> None: raise typer.Exit(130) from None +app.command("ask")(ask_command) +app.command("mem-clean")(mem_clean_command) app.command("insert")(insert_command) app.command("query")(query_command) app.command("clean")(clean_command) diff --git a/src/heta/cli/ask.py b/src/heta/cli/ask.py new file mode 100644 index 0000000..543eed1 --- /dev/null +++ b/src/heta/cli/ask.py @@ -0,0 +1,82 @@ +"""CLI command: heta ask.""" + +from __future__ import annotations + +import typer +from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel +from rich.text import Text + +from heta.config.io import load_config +from heta.query.smart_query import smart_query + +console = Console() + +_SOURCE_STYLES = { + "memory": ("● memory", "bold green"), + "kb": ("● KB", "bold cyan"), + "both": ("● memory + KB", "bold magenta"), +} + + +def ask_command( + question: str = typer.Argument(..., help="Question to ask."), + top_k: int = typer.Option(5, "--top-k", "-k", help="Results per layer / KB vector match."), + debug: bool = typer.Option(False, "--debug", "-d", help="Print agent steps, memory evidence, and KB output."), +) -> None: + """Answer a question via an outer agent that decides between memory and the KB.""" + config = load_config() + if config is None: + console.print("[red]Heta is not initialised. Run `heta init` first.[/red]") + raise typer.Exit(1) + + with console.status("[cyan]Thinking...[/cyan]"): + result = smart_query(question, config, top_k=top_k) + + if debug: + console.print("\n[bold yellow]── DEBUG ──[/bold yellow]") + console.print(f"agent steps: {' → '.join(result.agent_steps) or '(none)'}") + + if result.memory_evidence: + console.print("\n[bold]memory evidence:[/bold]") + for layer_ev in result.memory_evidence: + console.print(f" [bold]{layer_ev.layer}[/bold] ({len(layer_ev.items)} hits)") + for i, item in enumerate(layer_ev.items, 1): + score = item.get("score", 0) + console.print(f" [dim][{i}; score={score:.4f}][/dim]") + if layer_ev.layer == "raw": + console.print(f" {item.get('text_content', '')}") + elif layer_ev.layer == "episode": + console.print(f" {item.get('summary', '')}") + elif layer_ev.layer == "kb_insight": + console.print(f" [dim]source:[/dim] {item.get('source_path', '')}") + console.print(f" {item.get('insight', '')}") + else: + console.print(f" {item.get('fact_text', '')}") + + if result.kb_result: + console.print("\n[bold]kb result:[/bold]") + paths = [s.path for s in result.kb_result.sources] + console.print(f" used sources: {paths or '(empty)'}") + console.print(f" written_back: {result.written_back}") + console.print("[bold yellow]──────────[/bold yellow]\n") + + label, style = _SOURCE_STYLES[result.source] + header = Text() + header.append("Source: ") + header.append(label, style=style) + if result.written_back: + header.append(f" ({result.written_back} memories written back)", style="dim") + + console.print(header) + console.print() + console.print(Panel(Markdown(result.answer), border_style="cyan")) + + if result.kb_result and result.kb_result.sources: + console.print("[dim]KB Sources:[/dim]") + for src in result.kb_result.sources: + title = src.title or src.path + heading = f" — {src.heading_path}" if src.heading_path else "" + console.print(f" [dim][{src.wiki_id}][/dim] {title}{heading}") + console.print() diff --git a/src/heta/cli/mem_clean.py b/src/heta/cli/mem_clean.py new file mode 100644 index 0000000..4d0b21d --- /dev/null +++ b/src/heta/cli/mem_clean.py @@ -0,0 +1,39 @@ +"""`heta mem-clean` command — wipe all memory data.""" + +from __future__ import annotations + +import typer +from rich.console import Console +from rich.prompt import Confirm + +from heta.mem.clean import clean_memory +from heta.mem.db import get_connection, init_db +from heta.mem.paths import db_path, ensure_mem_dir + +console = Console() + + +def mem_clean_command( + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), +) -> None: + """Wipe all memory (personal + KB cache). Schema is preserved.""" + if not yes and not Confirm.ask( + "Delete all memory data? This cannot be undone.", + default=False, + ): + console.print("[dim]Cancelled.[/dim]") + raise typer.Exit(0) + + ensure_mem_dir() + conn = get_connection(db_path(), with_vec=True) + init_db(conn) + result = clean_memory(conn) + conn.close() + + console.print("[green]✓[/green] Memory cleared.") + console.print(f" [dim]sessions:[/dim] {result.deleted_sessions}") + console.print(f" [dim]L0 turns:[/dim] {result.deleted_l0_turns}") + console.print(f" [dim]L1 episodes:[/dim] {result.deleted_l1_episodes}") + console.print(f" [dim]L2 facts:[/dim] {result.deleted_l2_facts}") + console.print(f" [dim]KB insights:[/dim] {result.deleted_kb_insights}") + console.print(f" [dim]memory_meta rows:[/dim] {result.deleted_meta}") diff --git a/src/heta/mem/clean.py b/src/heta/mem/clean.py index f2e0f11..9bf8d17 100644 --- a/src/heta/mem/clean.py +++ b/src/heta/mem/clean.py @@ -12,6 +12,7 @@ class CleanMemoryResult: deleted_l0_turns: int deleted_l1_episodes: int deleted_l2_facts: int + deleted_kb_insights: int deleted_meta: int @@ -21,16 +22,29 @@ def clean_memory(conn: sqlite3.Connection) -> CleanMemoryResult: turns = _count(conn, "l0_turn") episodes = _count(conn, "l1_episodic") facts = _count(conn, "l2_semantic") + insights = _count(conn, "kb_insight") meta = _count(conn, "memory_meta") - # vec0 and FTS5 virtual tables must be cleared before the main tables - # because they reference the same memory_ids. + # vec0 and FTS5 virtual tables must be cleared before main tables conn.execute("DELETE FROM l2_fact_vec") conn.execute("DELETE FROM l1_episode_vec") conn.execute("DELETE FROM l0_turn_fts") + conn.execute("DELETE FROM kb_insight_vec") + # legacy vec tables (may exist on older DBs) + for t in ("kb_chunk_vec", "kb_qa_vec"): + try: + conn.execute(f"DELETE FROM {t}") + except Exception: + pass - # FK cascade handles l1_episodic / l2_semantic when memory_meta is deleted, - # but delete leaf tables explicitly first to avoid any ordering issues. + # delete leaf tables first to avoid FK ordering issues + conn.execute("DELETE FROM kb_insight") + # legacy tables + for t in ("kb_qa_chunk", "kb_qa", "kb_chunk", "kb_source"): + try: + conn.execute(f"DELETE FROM {t}") + except Exception: + pass conn.execute("DELETE FROM l2_semantic") conn.execute("DELETE FROM l1_episodic") conn.execute("DELETE FROM memory_meta") @@ -43,6 +57,7 @@ def clean_memory(conn: sqlite3.Connection) -> CleanMemoryResult: deleted_l0_turns=turns, deleted_l1_episodes=episodes, deleted_l2_facts=facts, + deleted_kb_insights=insights, deleted_meta=meta, ) diff --git a/src/heta/mem/db.py b/src/heta/mem/db.py index e6e8c43..edabdac 100644 --- a/src/heta/mem/db.py +++ b/src/heta/mem/db.py @@ -86,6 +86,19 @@ def init_db(conn: sqlite3.Connection) -> None: ); CREATE INDEX IF NOT EXISTS idx_l2_predicate ON l2_semantic(predicate); + + CREATE TABLE IF NOT EXISTS kb_insight ( + memory_id TEXT PRIMARY KEY REFERENCES memory_meta(memory_id) ON DELETE CASCADE, + insight TEXT NOT NULL, + question TEXT, + source_path TEXT NOT NULL, + wiki_id INTEGER, + heading_path TEXT, + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_kb_insight_source ON kb_insight(source_path); + CREATE INDEX IF NOT EXISTS idx_kb_insight_wiki ON kb_insight(wiki_id); """) _migrate(conn) _ensure_vec_table(conn) @@ -94,6 +107,8 @@ def init_db(conn: sqlite3.Connection) -> None: def _migrate(conn: sqlite3.Connection) -> None: """Add columns introduced after initial schema creation.""" + tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + l2_cols = {row[1] for row in conn.execute("PRAGMA table_info(l2_semantic)")} if "fact_text" not in l2_cols: conn.execute("ALTER TABLE l2_semantic ADD COLUMN fact_text TEXT NOT NULL DEFAULT ''") @@ -110,6 +125,29 @@ def _migrate(conn: sqlite3.Connection) -> None: if "when_precision" not in l1_cols: conn.execute("ALTER TABLE l1_episodic ADD COLUMN when_precision TEXT") + # legacy tables from earlier design — kept so existing DBs don't break + if "kb_source" not in tables: + conn.execute("""CREATE TABLE kb_source ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memory_id TEXT NOT NULL, + wiki_id INTEGER, page_title TEXT, + page_path TEXT NOT NULL, heading_path TEXT, + synced_at INTEGER NOT NULL)""") + if "kb_chunk" not in tables: + conn.execute("""CREATE TABLE kb_chunk ( + memory_id TEXT PRIMARY KEY, wiki_id INTEGER, page_title TEXT, + page_path TEXT NOT NULL, heading_path TEXT, + chunk_text TEXT NOT NULL, synced_at INTEGER NOT NULL)""") + if "kb_qa" not in tables: + conn.execute("""CREATE TABLE kb_qa ( + memory_id TEXT PRIMARY KEY, + question TEXT NOT NULL, answer TEXT NOT NULL, + created_at INTEGER NOT NULL)""") + if "kb_qa_chunk" not in tables: + conn.execute("""CREATE TABLE kb_qa_chunk ( + qa_memory_id TEXT NOT NULL, chunk_memory_id TEXT NOT NULL, + PRIMARY KEY (qa_memory_id, chunk_memory_id))""") + def _ensure_vec_table(conn: sqlite3.Connection) -> None: conn.execute( @@ -131,3 +169,22 @@ def _ensure_vec_table(conn: sqlite3.Connection) -> None: text_content )""" ) + conn.execute( + f"""CREATE VIRTUAL TABLE IF NOT EXISTS kb_insight_vec USING vec0( + memory_id TEXT PRIMARY KEY, + embedding FLOAT[{EMBEDDING_DIM}] + )""" + ) + # legacy vec tables — kept so existing DBs don't break + conn.execute( + f"""CREATE VIRTUAL TABLE IF NOT EXISTS kb_chunk_vec USING vec0( + memory_id TEXT PRIMARY KEY, + embedding FLOAT[{EMBEDDING_DIM}] + )""" + ) + conn.execute( + f"""CREATE VIRTUAL TABLE IF NOT EXISTS kb_qa_vec USING vec0( + memory_id TEXT PRIMARY KEY, + embedding FLOAT[{EMBEDDING_DIM}] + )""" + ) diff --git a/src/heta/mem/kb_store.py b/src/heta/mem/kb_store.py new file mode 100644 index 0000000..3fb06a3 --- /dev/null +++ b/src/heta/mem/kb_store.py @@ -0,0 +1,54 @@ +"""CRUD and search operations for kb_insight.""" + +from __future__ import annotations + +import sqlite3 + +import sqlite_vec + +from heta.mem.models import KBInsight + + +def insert_kb_insight(conn: sqlite3.Connection, insight: KBInsight) -> None: + conn.execute( + """INSERT INTO kb_insight + (memory_id, insight, question, source_path, wiki_id, heading_path, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (insight.memory_id, insight.insight, insight.question, insight.source_path, + insight.wiki_id, insight.heading_path, insight.created_at), + ) + + +def insert_insight_embedding( + conn: sqlite3.Connection, memory_id: str, embedding: list[float] +) -> None: + conn.execute( + "INSERT INTO kb_insight_vec (memory_id, embedding) VALUES (?, ?)", + (memory_id, sqlite_vec.serialize_float32(embedding)), + ) + + +def search_kb_insights( + conn: sqlite3.Connection, + embedding: list[float], + top_k: int = 5, +) -> list[dict]: + rows = conn.execute( + """SELECT i.memory_id, i.insight, i.source_path, v.distance + FROM kb_insight_vec v + JOIN kb_insight i ON i.memory_id = v.memory_id + JOIN memory_meta m ON m.memory_id = i.memory_id + WHERE v.embedding MATCH ? AND k = ? + AND m.status = 'active' + ORDER BY v.distance""", + (sqlite_vec.serialize_float32(embedding), top_k), + ).fetchall() + return [ + { + "memory_id": r["memory_id"], + "insight": r["insight"], + "source_path": r["source_path"], + "score": 1.0 / (1.0 + float(r["distance"])), + } + for r in rows + ] diff --git a/src/heta/mem/kb_writer.py b/src/heta/mem/kb_writer.py new file mode 100644 index 0000000..8c483a4 --- /dev/null +++ b/src/heta/mem/kb_writer.py @@ -0,0 +1,123 @@ +"""Extract and store KB insights into memory.""" + +from __future__ import annotations + +import json +import logging +import time +import uuid +from pathlib import Path + +from heta.config.schema import HetaConfig +from heta.mem import meta_store +from heta.mem.client import build_client, build_embedding_client, extra_body +from heta.mem.db import get_connection, init_db +from heta.mem.embedder import embed_text +from heta.mem.kb_store import insert_insight_embedding, insert_kb_insight +from heta.mem.models import KBInsight, MemoryMeta +from heta.mem.paths import db_path, ensure_mem_dir +from heta.mem.prompts import KB_INSIGHT_EXTRACTION_PROMPT +from heta.query.tools import read_page + +logger = logging.getLogger(__name__) + + +def remember_kb_insights( + question: str, + sources, # list[QuerySource] — already validated to "used" by the KB agent + config: HetaConfig, + base_dir: Path | None = None, +) -> int: + """Distil KB page content into insights and store them. Returns number of insights written.""" + if not sources: + return 0 + + ensure_mem_dir() + conn = get_connection(db_path(), with_vec=True) + init_db(conn) + + llm_client, llm_model = build_client(config) + emb_client, emb_model = build_embedding_client(config) + now = int(time.time()) + total = 0 + + for qs in sources: + path = qs.path + page_content = read_page(path, base_dir) + if page_content.startswith("error:"): + logger.warning("skip insight extraction for %s: %s", path, page_content) + continue + + wiki_id = qs.wiki_id + heading_path = qs.heading_path + + insights = _extract_insights(llm_client, llm_model, question, page_content, config) + if not insights: + logger.info("no insights extracted from %s", path) + continue + + for insight_text in insights: + memory_id = str(uuid.uuid4()) + meta = MemoryMeta( + memory_id=memory_id, + memory_type="kb_insight", + session_id=None, + origin="kb_insight", + kb_uid=str(wiki_id) if wiki_id is not None else None, + created_at=now, + last_access_at=now, + ) + insight = KBInsight( + memory_id=memory_id, + insight=insight_text, + question=question, + source_path=path, + wiki_id=wiki_id, + heading_path=heading_path, + created_at=now, + ) + meta_store.insert_meta(conn, meta) + insert_kb_insight(conn, insight) + emb = embed_text(emb_client, emb_model, insight_text) + insert_insight_embedding(conn, memory_id, emb) + total += 1 + + conn.commit() + conn.close() + return total + + +def _extract_insights(client, model: str, question: str, page_content: str, config) -> list[str]: + user_msg = f"Question:\n{question}\n\nKB page content:\n{page_content}" + kwargs = { + "model": model, + "messages": [ + {"role": "system", "content": KB_INSIGHT_EXTRACTION_PROMPT}, + {"role": "user", "content": user_msg}, + ], + "temperature": 0.2, + } + body = extra_body(config) + if body: + kwargs["extra_body"] = body + try: + response = client.chat.completions.create(**kwargs) + raw = (response.choices[0].message.content or "").strip() + return _parse_insights(raw) + except Exception: + logger.exception("insight extraction failed for question: %s", question[:80]) + return [] + + +def _parse_insights(raw: str) -> list[str]: + text = raw.strip() + if text.startswith("```"): + lines = text.splitlines() + text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]) + try: + data = json.loads(text) + items = data.get("insights", []) + return [s for s in items if isinstance(s, str) and s.strip()] + except (json.JSONDecodeError, AttributeError): + logger.warning("failed to parse insights response: %s", raw[:200]) + return [] diff --git a/src/heta/mem/l0_search.py b/src/heta/mem/l0_search.py index 8821b6a..1ad5fed 100644 --- a/src/heta/mem/l0_search.py +++ b/src/heta/mem/l0_search.py @@ -5,11 +5,19 @@ import sqlite3 +def _build_fts_query(query: str) -> str: + """Build an FTS5 OR query from individual tokens, avoiding syntax errors.""" + import re + tokens = re.findall(r'[\w一-鿿]+', query) + if not tokens: + return '""' + # quote each token individually to handle special chars, then OR them + return " OR ".join('"' + t.replace('"', '""') + '"' for t in tokens) + + def search_turns(conn: sqlite3.Connection, query: str, top_k: int = 3) -> list[dict]: """FTS5 search on raw turn text. Returns matching turns with context.""" - # wrap in quotes for phrase search to avoid FTS5 syntax errors on - # punctuation and special characters - fts_query = '"' + query.replace('"', '""') + '"' + fts_query = _build_fts_query(query) try: rows = conn.execute( """ diff --git a/src/heta/mem/models.py b/src/heta/mem/models.py index e11a8bd..f2a9965 100644 --- a/src/heta/mem/models.py +++ b/src/heta/mem/models.py @@ -69,3 +69,14 @@ class L2Semantic: when_text: str | None = None # original relative expression ("下个月") when_resolved: str | None = None # variable-precision: "2026-06" / "2026-05-12" when_precision: str | None = None # day / week / month / year + + +@dataclass +class KBInsight: + memory_id: str + insight: str # distilled knowledge point + source_path: str # KB page this was extracted from + created_at: int + question: str | None = None + wiki_id: int | None = None + heading_path: str | None = None diff --git a/src/heta/mem/prompts.py b/src/heta/mem/prompts.py index 2f9639e..68a01f9 100644 --- a/src/heta/mem/prompts.py +++ b/src/heta/mem/prompts.py @@ -52,25 +52,65 @@ If unresolvable, both fields are null. """ -RECALL_RANKER_PROMPT = """\ -You are a memory-layer ranker for personal memory question answering. -Compare the retrieved evidence from all memory layers and rank the layers from best to worst for answering the given question. -Then synthesise a concise answer using the best available evidence. -Return STRICT JSON only. Do not output markdown or extra text. +RECALL_RANKING_PROMPT = """\ +You are a memory-layer relevance ranker. +Given a question and evidence retrieved from multiple memory layers, rank the layers from most to least relevant for answering the question. +Return STRICT JSON only. Do not output anything outside the JSON object. Schema: -{"ranking": ["best_layer", "second_layer", "third_layer"], "answer": "concise answer in the same language as the question", "reason": "one sentence explaining the ranking"} +{"ranking": ["best_layer", "second_layer", ...], "reason": "one sentence explaining which layer is most relevant and why"} Available memory layers: -- raw (L0): original input text preserved verbatim. Exact wording, full context. -- episode (L1): bounded episodic memories — coherent events, experiences, meetings, plans with participants, time, location, and reason. -- atomic_fact (L2): compact factual memories — stable attributes, relationships, preferences, outcomes. +- raw (L0): original input text preserved verbatim. +- episode (L1): bounded episodic memories — events, experiences, plans. +- atomic_fact (L2): compact factual memories — attributes, relationships, outcomes. +- kb_insight: distilled knowledge points extracted from the knowledge base. + +Rules: +- Rank based on relevance to the question only — do not attempt to answer the question here. +- If a layer has no results, rank it last. +- If no layer has any relevant evidence, return {"ranking": [], "reason": "no relevant evidence found"}. +""" + +RECALL_ANSWER_PROMPT = """\ +You are a strictly evidence-grounded answer generator. +Your task: answer the question using ONLY the evidence provided. No outside knowledge allowed. + +Return STRICT JSON only. Do not output anything outside the JSON object. + +Schema (sufficient): {"answer": "", "sufficient": true} +Schema (insufficient): {"answer": "[INSUFFICIENT]", "sufficient": false} + +CRITICAL rules: +- Use ONLY information explicitly stated in the evidence. Do NOT infer, extrapolate, or fill in details from your training knowledge. +- If the evidence does not explicitly contain what is needed to answer the question, output {"answer": "[INSUFFICIENT]", "sufficient": false}. +- "Thematically related" evidence is NOT sufficient. The evidence must directly state the specific information being asked. +- If the question asks for specific details that are not literally present in the evidence, output [INSUFFICIENT]. +- When in doubt, output [INSUFFICIENT]. + +Answer format (when sufficient): +- Write in Markdown with appropriate structure (headers, lists, code blocks). +- Answer in the SAME language as the question. +- Do NOT include a Sources or References section. +""" + +KB_INSIGHT_EXTRACTION_PROMPT = """\ +You are a knowledge distillation engine. Given a user question and a KB page, extract concise, reusable insights from the page that are directly useful for answering the question. + +Process: +1. Reason step by step: which parts of the KB content are relevant to the question? +2. Distill those parts into self-contained insight statements. +3. Output ONLY the final JSON — no other text. + +Schema: +{"insights": ["insight 1", "insight 2", ...]} Rules: -- Rank the layer that most directly answers the question first. -- If a layer has no relevant evidence, rank it last. -- The answer must be grounded in the evidence; do not invent facts. -- If none of the layers contain relevant evidence, set answer to "No relevant memory found." +- Each insight must be self-contained and understandable without the original page. +- Only include insights relevant to the question — filter out unrelated content. +- LANGUAGE RULE: write insights in the SAME language as the question. +- Aim for 3–8 insights. Do not pad with trivial or redundant statements. +- If the page contains nothing relevant, return {"insights": []}. """ CONFLICT_JUDGE_PROMPT = """\ diff --git a/src/heta/mem/recall.py b/src/heta/mem/recall.py index 56978c8..a5c12c6 100644 --- a/src/heta/mem/recall.py +++ b/src/heta/mem/recall.py @@ -10,15 +10,25 @@ from heta.mem.client import build_client, build_embedding_client, extra_body from heta.mem.db import get_connection, init_db from heta.mem.embedder import embed_text +from heta.mem.kb_store import search_kb_insights from heta.mem.l0_search import search_turns from heta.mem.l1_search import search_episodes from heta.mem.l2_store import search_similar_facts from heta.mem.paths import db_path, ensure_mem_dir -from heta.mem.prompts import RECALL_RANKER_PROMPT +from heta.mem.prompts import RECALL_ANSWER_PROMPT, RECALL_RANKING_PROMPT logger = logging.getLogger(__name__) +def _open_conn_and_embed(query: str, config: HetaConfig): + ensure_mem_dir() + conn = get_connection(db_path(), with_vec=True) + init_db(conn) + emb_client, emb_model = build_embedding_client(config) + embedding = embed_text(emb_client, emb_model, query) + return conn, embedding + + @dataclass class LayerEvidence: layer: str # raw / episode / atomic_fact @@ -32,31 +42,43 @@ class RecallResult: answer: str reason: str evidence: list[LayerEvidence] + sufficient: bool = False -def recall(query: str, config: HetaConfig, top_k: int = 10) -> RecallResult: - ensure_mem_dir() - conn = get_connection(db_path(), with_vec=True) - init_db(conn) +def retrieve_evidence(query: str, config: HetaConfig, top_k: int = 5) -> list[LayerEvidence]: + """Pure retrieval — no LLM calls. Used by smart_query to inject context into the KB agent.""" + conn, query_embedding = _open_conn_and_embed(query, config) + l0_hits = search_turns(conn, query, top_k=top_k) + l1_hits = search_episodes(conn, query_embedding, top_k=top_k) + l2_hits = search_similar_facts(conn, query_embedding, top_k=top_k) + kb_insight_hits = search_kb_insights(conn, query_embedding, top_k=top_k) + conn.close() + return [ + LayerEvidence(layer="raw", items=l0_hits), + LayerEvidence(layer="episode", items=l1_hits), + LayerEvidence(layer="atomic_fact", items=l2_hits), + LayerEvidence(layer="kb_insight", items=kb_insight_hits), + ] - llm_client, llm_model = build_client(config) - emb_client, emb_model = build_embedding_client(config) - query_embedding = embed_text(emb_client, emb_model, query) +def recall(query: str, config: HetaConfig, top_k: int = 10) -> RecallResult: + conn, query_embedding = _open_conn_and_embed(query, config) + llm_client, llm_model = build_client(config) l0_hits = search_turns(conn, query, top_k=top_k) l1_hits = search_episodes(conn, query_embedding, top_k=top_k) l2_hits = search_similar_facts(conn, query_embedding, top_k=top_k) - + kb_insight_hits = search_kb_insights(conn, query_embedding, top_k=top_k) conn.close() evidence = [ LayerEvidence(layer="raw", items=l0_hits), LayerEvidence(layer="episode", items=l1_hits), LayerEvidence(layer="atomic_fact", items=l2_hits), + LayerEvidence(layer="kb_insight", items=kb_insight_hits), ] - ranking, answer, reason = _rank( + ranking, answer, reason, sufficient = _rank( query=query, evidence=evidence, client=llm_client, @@ -70,6 +92,7 @@ def recall(query: str, config: HetaConfig, top_k: int = 10) -> RecallResult: answer=answer, reason=reason, evidence=evidence, + sufficient=sufficient, ) @@ -79,28 +102,90 @@ def _rank( client, model: str, config: HetaConfig, -) -> tuple[list[str], str, str]: - evidence_text = _format_evidence(evidence) - user_msg = f"Question:\n{query}\n\nRetrieved evidence from each memory layer:\n{evidence_text}" +) -> tuple[list[str], str, str, bool]: + """Two-phase: rank layers first, then generate a strictly grounded answer.""" + evidence_text = format_evidence(evidence) + body = extra_body(config) + + # Phase A: rank layers (no answer generation) + ranking, reason = _rank_layers( + query=query, + evidence_text=evidence_text, + client=client, + model=model, + extra=body, + ) + + # Phase B: generate grounded answer (or [INSUFFICIENT]) + answer, sufficient = _generate_grounded_answer( + query=query, + evidence_text=evidence_text, + client=client, + model=model, + extra=body, + ) + + return ranking, answer, reason, sufficient + +def _rank_layers( + query: str, + evidence_text: str, + client, + model: str, + extra: dict | None, +) -> tuple[list[str], str]: kwargs: dict = { "model": model, "messages": [ - {"role": "system", "content": RECALL_RANKER_PROMPT}, - {"role": "user", "content": user_msg}, + {"role": "system", "content": RECALL_RANKING_PROMPT}, + {"role": "user", "content": f"Question:\n{query}\n\nEvidence:\n{evidence_text}"}, + ], + "temperature": 0.1, + } + if extra: + kwargs["extra_body"] = extra + try: + raw = (client.chat.completions.create(**kwargs).choices[0].message.content or "").strip() + data = _parse_json(raw) + return data.get("ranking", []), data.get("reason", "") + except Exception: + logger.warning("ranking call failed", exc_info=True) + return [], "" + + +def _generate_grounded_answer( + query: str, + evidence_text: str, + client, + model: str, + extra: dict | None, +) -> tuple[str, bool]: + kwargs: dict = { + "model": model, + "messages": [ + {"role": "system", "content": RECALL_ANSWER_PROMPT}, + {"role": "user", "content": f"Question:\n{query}\n\nEvidence:\n{evidence_text}"}, ], "temperature": 0.2, } - body = extra_body(config) - if body: - kwargs["extra_body"] = body + if extra: + kwargs["extra_body"] = extra + try: + raw = (client.chat.completions.create(**kwargs).choices[0].message.content or "").strip() + data = _parse_json(raw) + answer = data.get("answer", "") + sufficient = bool(data.get("sufficient", False)) + if answer == "[INSUFFICIENT]" or not sufficient: + return "", False + return answer, True + except Exception: + logger.warning("answer generation call failed", exc_info=True) + return "", False - response = client.chat.completions.create(**kwargs) - raw = (response.choices[0].message.content or "").strip() - return _parse_rank_response(raw) -def _format_evidence(evidence: list[LayerEvidence]) -> str: +def format_evidence(evidence: list[LayerEvidence]) -> str: parts = [] for layer_ev in evidence: parts.append(f"## {layer_ev.layer}") @@ -113,22 +198,21 @@ def _format_evidence(evidence: list[LayerEvidence]) -> str: parts.append(f"[{i}; score={score:.4f}] {item['text_content']}") elif layer_ev.layer == "episode": parts.append(f"[{i}; score={score:.4f}] {item['summary']}") + elif layer_ev.layer == "kb_insight": + src = item.get("source_path", "") + parts.append(f"[{i}; score={score:.4f}] [{src}] {item.get('insight', '')}") else: parts.append(f"[{i}; score={score:.4f}] {item['fact_text']}") return "\n".join(parts) -def _parse_rank_response(raw: str) -> tuple[list[str], str, str]: - text = raw +def _parse_json(raw: str) -> dict: + text = raw.strip() if text.startswith("```"): lines = text.splitlines() text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]) try: - data = json.loads(text) - ranking = data.get("ranking", []) - answer = data.get("answer", "") - reason = data.get("reason", "") - return ranking, answer, reason + return json.loads(text) except (json.JSONDecodeError, AttributeError): - logger.warning("Failed to parse ranker response: %s", raw[:200]) - return [], raw, "" + logger.warning("Failed to parse LLM JSON response: %s", raw[:200]) + return {} diff --git a/src/heta/query/pipeline.py b/src/heta/query/pipeline.py index 84ddc92..198f8ca 100644 --- a/src/heta/query/pipeline.py +++ b/src/heta/query/pipeline.py @@ -15,7 +15,6 @@ def run_wiki_query( config: HetaConfig, *, top_k: int = 5, - extra_context: str | None = None, base_dir: Path | None = None, ) -> QueryResult: if not question.strip(): @@ -27,6 +26,5 @@ def run_wiki_query( config=config, base_dir=base_dir, top_k=top_k, - extra_context=extra_context, ) diff --git a/src/heta/query/smart_query.py b/src/heta/query/smart_query.py new file mode 100644 index 0000000..0ffad6c --- /dev/null +++ b/src/heta/query/smart_query.py @@ -0,0 +1,260 @@ +"""Outer agent loop with two tools: recall_memory and query_kb.""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +from heta.config.schema import HetaConfig +from heta.mem.client import build_client, extra_body +from heta.mem.kb_writer import remember_kb_insights +from heta.mem.recall import LayerEvidence, format_evidence, retrieve_evidence +from heta.query.models import QueryResult + +logger = logging.getLogger(__name__) + +MAX_OUTER_STEPS = 5 + +_NO_INFO_PHRASES = [ + "no relevant", + "not found", + "unable to find", + "cannot find", + "找不到", + "没有相关", + "无法找到", +] + +OUTER_TOOLS = [ + { + "type": "function", + "function": { + "name": "recall_memory", + "description": ( + "Search personal memory layers (past conversation turns, episodic events, " + "atomic facts, and previously cached KB insights). Fast, no LLM calls. " + "Returns formatted evidence grouped by layer; '(no results)' means a layer is empty." + ), + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "The query to search memory with."} + }, + "required": ["query"], + "additionalProperties": False, + }, + }, + }, + { + "type": "function", + "function": { + "name": "query_kb", + "description": ( + "Run a deep wiki knowledge-base search via a sub-agent that can read pages " + "and perform semantic search. Slower but authoritative. Use only when memory is " + "insufficient. Returns a synthesized answer string." + ), + "parameters": { + "type": "object", + "properties": { + "question": {"type": "string", "description": "The question to ask the KB sub-agent."} + }, + "required": ["question"], + "additionalProperties": False, + }, + }, + }, +] + +OUTER_SYSTEM_PROMPT = """\ +You are a personal assistant with access to two information sources via tools. + +Tools: +- recall_memory(query): fast search over personal memory (past conversations, + episodes, facts, and previously cached KB insights). Use first. +- query_kb(question): deep search over the project knowledge base via a + sub-agent that reads pages. Slower but authoritative. Use only when memory + is insufficient. + +Decision strategy: +1. Always call recall_memory FIRST with the user's question (unless the question + is a trivial greeting or meta-message that needs no retrieval). +2. Read the evidence carefully. A section showing "(no results)" means that + layer is empty. A score below ~0.3 usually means weak relevance. +3. If memory contains the specific information the question asks for, answer + directly from memory. Do NOT call query_kb. +4. If memory is empty or only thematically related (mentions the topic but + not the specific answer), call query_kb. +5. Special case: if the question is about a personal experience ("what did I + do yesterday", "我上次去哪了") and memory has no hits, answer that you + don't have that information. Do NOT search the KB for personal events. +6. After getting tool results, produce the final answer as plain Markdown in + the SAME language as the question. Do not mention the tools or your + internal reasoning. Do not include a "Sources" section. +""" + + +@dataclass +class SmartQueryResult: + answer: str + source: Literal["memory", "kb", "both"] + memory_evidence: list[LayerEvidence] = field(default_factory=list) + kb_result: QueryResult | None = None + written_back: int = 0 + agent_steps: list[str] = field(default_factory=list) + + +@dataclass +class _State: + memory_evidence: list[LayerEvidence] = field(default_factory=list) + kb_result: QueryResult | None = None + written_back: int = 0 + used_memory: bool = False + used_kb: bool = False + agent_steps: list[str] = field(default_factory=list) + + +def smart_query( + question: str, + config: HetaConfig, + top_k: int = 5, + base_dir: Path | None = None, +) -> SmartQueryResult: + """Outer agent loop: lets an LLM decide when to recall memory vs. query KB.""" + state = _State() + client, model = build_client(config) + messages: list[dict[str, Any]] = [{"role": "user", "content": question}] + + for _ in range(MAX_OUTER_STEPS): + resp = _chat(client, model, messages, tools=OUTER_TOOLS, config=config) + msg = resp.choices[0].message + tool_calls = list(msg.tool_calls or []) + + if not tool_calls: + return _build_result(state, answer=msg.content or "") + + assistant_msg: dict[str, Any] = {"role": "assistant"} + if msg.content: + assistant_msg["content"] = msg.content + assistant_msg["tool_calls"] = [ + { + "id": tc.id, + "type": "function", + "function": {"name": tc.function.name, "arguments": tc.function.arguments}, + } + for tc in tool_calls + ] + messages.append(assistant_msg) + + for tc in tool_calls: + result = _exec_tool(tc, config, top_k, base_dir, state) + messages.append({"role": "tool", "tool_call_id": tc.id, "content": result}) + + # Step limit reached — force a final answer with no tools + messages.append( + {"role": "user", "content": "Step limit reached. Answer with the evidence already gathered, or say you don't know."} + ) + final = _chat(client, model, messages, tools=None, config=config) + return _build_result(state, answer=final.choices[0].message.content or "") + + +def _build_result(state: _State, *, answer: str) -> SmartQueryResult: + memory_has_hits = any(layer.items for layer in state.memory_evidence) + if state.used_kb and state.used_memory and memory_has_hits: + source: Literal["memory", "kb", "both"] = "both" + elif state.used_kb: + source = "kb" + else: + source = "memory" + return SmartQueryResult( + answer=answer, + source=source, + memory_evidence=state.memory_evidence, + kb_result=state.kb_result, + written_back=state.written_back, + agent_steps=list(state.agent_steps), + ) + + +def _exec_tool(tool_call: Any, config: HetaConfig, top_k: int, base_dir: Path | None, state: _State) -> str: + name = tool_call.function.name + try: + args = json.loads(tool_call.function.arguments or "{}") + except json.JSONDecodeError as exc: + return f"error: invalid tool arguments: {exc}" + + if name == "recall_memory": + return _exec_recall_memory(str(args.get("query", "")), config, top_k, state) + if name == "query_kb": + return _exec_query_kb(str(args.get("question", "")), config, top_k, base_dir, state) + return f"error: unknown tool {name}" + + +def _exec_recall_memory(query: str, config: HetaConfig, top_k: int, state: _State) -> str: + if not query.strip(): + return "error: empty query" + try: + evidence = retrieve_evidence(query, config, top_k=top_k) + except Exception as exc: + logger.exception("recall_memory failed") + return f"error: {exc}" + state.memory_evidence = evidence + state.used_memory = True + state.agent_steps.append("recall_memory") + return format_evidence(evidence) + + +def _exec_query_kb(question: str, config: HetaConfig, top_k: int, base_dir: Path | None, state: _State) -> str: + if not question.strip(): + return "error: empty question" + from heta.query.agent import run_query_agent + + try: + kb_result = run_query_agent( + question=question, + config=config, + base_dir=base_dir, + top_k=top_k, + ) + except Exception as exc: + logger.exception("query_kb failed") + return f"error: {exc}" + + state.kb_result = kb_result + state.used_kb = True + state.agent_steps.append("query_kb") + + if _kb_has_info(kb_result.answer) and kb_result.sources: + try: + state.written_back = remember_kb_insights( + question=question, + sources=kb_result.sources, + config=config, + base_dir=base_dir, + ) + except Exception: + logger.exception("kb write-back failed") + + return kb_result.answer + + +def _chat(client, model: str, messages: list[dict[str, Any]], *, tools, config: HetaConfig): + kwargs: dict[str, Any] = { + "model": model, + "messages": [{"role": "system", "content": OUTER_SYSTEM_PROMPT}, *messages], + "temperature": 0.2, + } + if tools: + kwargs["tools"] = tools + body = extra_body(config) + if body: + kwargs["extra_body"] = body + return client.chat.completions.create(**kwargs) + + +def _kb_has_info(answer: str) -> bool: + lower = answer.lower() + return not any(phrase in lower for phrase in _NO_INFO_PHRASES) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c85d84e --- /dev/null +++ b/uv.lock @@ -0,0 +1,850 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jiter" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/2e/a9959997739c403378d0a4a3a1c4ed80b60aeace216c4d37b303a9fc60a4/jiter-0.14.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:02f36a5c700f105ac04a6556fe664a59037a2c200db3b7e88784fac2ddf02531", size = 316927, upload-time = "2026-04-10T14:25:40.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/72/b6de8a531e0adbadd839bec301165feb1fccf00e9ff55073ba2dd20f0043/jiter-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41eab6c09ceffb6f0fe25e214b3068146edb1eda3649ca2aee2a061029c7ba2e", size = 321181, upload-time = "2026-04-10T14:25:42.621Z" }, + { url = "https://files.pythonhosted.org/packages/db/d8/2040b9efa13c917f855c40890ae4119fe02c25b7c7677d5b4fa820a851fc/jiter-0.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cf4d4c109641f9cfaf4a7b6aebd51654e405cd00fa9ebbf87163b8b97b325aa", size = 347387, upload-time = "2026-04-10T14:25:44.212Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/655c0ad5ce6a8e90f9068c175b8a236877d753e460762b3183c136db1c5b/jiter-0.14.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80c7b41a628e6be2213ad0ece763c5f88aa5ee003fa394d58acaaee1f4b8342", size = 373083, upload-time = "2026-04-10T14:25:45.55Z" }, + { url = "https://files.pythonhosted.org/packages/f1/66/549c40fa068f08710b7570869c306a051eb67a29758bd64f4114f730554c/jiter-0.14.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb3dbf7cc0d4dbe73cce307ebe7eefa7f73a7d3d854dd119ea0c243f03e40927", size = 463639, upload-time = "2026-04-10T14:25:47.452Z" }, + { url = "https://files.pythonhosted.org/packages/25/2f/97a32a05fed14ed58a18e181fdfb619e05163f3726b54ee6080ec0539c09/jiter-0.14.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7054adcdeb06b46efd17b5734f75817a44a2d06d3748e36c3a023a1bb52af9ec", size = 380735, upload-time = "2026-04-10T14:25:49.305Z" }, + { url = "https://files.pythonhosted.org/packages/2a/3b/4347e1d6c2a973d653bbb7a2d671a2d2426e54b52ba735b8ff0d0a29b75c/jiter-0.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d597cd1bf6790376f3fffc7c708766e57301d99a19314824ea0ccc9c3c70e1e2", size = 358632, upload-time = "2026-04-10T14:25:50.931Z" }, + { url = "https://files.pythonhosted.org/packages/ef/24/ca452fbf2ea33548ed30ce68a39a50442d3f7c9bf0704a7af958a930c057/jiter-0.14.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:df63a14878da754427926281626fd3ee249424a186e25a274e78176d42945264", size = 359969, upload-time = "2026-04-10T14:25:52.381Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a3/94470a0d199287caabeb4da2bb2ae5f6d17f3cf05dfc975d7cb064d58e0f/jiter-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ea73187627bcc5810e085df715e8a99da8bdfd96a7eb36b4b4df700ba6d4c9c", size = 397529, upload-time = "2026-04-10T14:25:53.801Z" }, + { url = "https://files.pythonhosted.org/packages/cf/71/6768edc09d7c45c39f093feb3de105fa718a3e982b5208b8a2ed6382b44b/jiter-0.14.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9f541eaf7bb8382367a1a23d6fc3d6aad57f8dd8c18c3c17f838bee20f217220", size = 522342, upload-time = "2026-04-10T14:25:55.396Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6b/5c2e17559a0f4e96e934479f7137df46c939e983fa05244e674815befb73/jiter-0.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:107465250de4fce00fdb47166bcd51df8e634e049541174fe3c71848e44f52ce", size = 556784, upload-time = "2026-04-10T14:25:56.927Z" }, + { url = "https://files.pythonhosted.org/packages/b1/83/c25f3556a60fc74d11199100f1b6cc0c006b815c8494dea8ca16fe398732/jiter-0.14.0-cp310-cp310-win32.whl", hash = "sha256:ffb2a08a406465bb076b7cc1df41d833106d3cf7905076cc73f0cb90078c7d10", size = 208439, upload-time = "2026-04-10T14:25:58.796Z" }, + { url = "https://files.pythonhosted.org/packages/2e/99/781a1b413f0989b7f2ea203b094b331685f1a35e52e0a45e5d000ecaab27/jiter-0.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb8b682d10cb0cce7ff4c1af7244af7022c9b01ae16d46c357bdd0df13afb25d", size = 204558, upload-time = "2026-04-10T14:26:00.208Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" }, + { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" }, + { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" }, + { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" }, + { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" }, + { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, + { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, + { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, + { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, + { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, + { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, + { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, + { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, + { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" }, + { url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" }, + { url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" }, + { url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" }, + { url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" }, + { url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" }, + { url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" }, + { url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" }, + { url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" }, + { url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, + { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, + { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, + { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, + { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, + { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, + { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, + { url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" }, + { url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" }, + { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, + { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, +] + +[[package]] +name = "little-heta" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "openai" }, + { name = "pypdf" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "sqlite-vec" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "openai", specifier = ">=1.0.0" }, + { name = "pypdf", specifier = ">=4.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, + { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "requests", specifier = ">=2.31.0" }, + { name = "rich", specifier = ">=13.7.0" }, + { name = "sqlite-vec", specifier = ">=0.1.6" }, + { name = "typer", specifier = ">=0.12.0" }, +] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.3" }] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "openai" +version = "2.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/a1/4d5e84cf51720fc1526cc49e10ac1961abcccb55b0efb3d970db1e9a2728/openai-2.36.0.tar.gz", hash = "sha256:139dea0edd2f1b30c33d46ae1a6929e03906254140318e4608e98fe8c566f2e7", size = 753003, upload-time = "2026-05-07T17:33:17.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/1c/5d43735b2553baae2a5e899dcbcd0670a86930d993184d72ca909bf11c9b/openai-2.36.0-py3-none-any.whl", hash = "sha256:143f6194b548dbc2c921af1f1b03b9f14c85fed8a75b5b516f5bcc11a2a50c63", size = 1302361, upload-time = "2026-05-07T17:33:15.063Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pypdf" +version = "6.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/58/6dd97d78a4b17a7a6b9d1c6ad23895abc41f0fdc49c553cc05bdfdcc36d0/pypdf-6.11.0.tar.gz", hash = "sha256:062b51c81b0910e6d2755e99e1c5547a0a23b7d0a32322af66240d8edcfabe87", size = 6453975, upload-time = "2026-05-09T13:26:48.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/b1/68feb7eb3b99f0c020b414234825f4a5d70e0126c18d933770e8c93a35fc/pypdf-6.11.0-py3-none-any.whl", hash = "sha256:769394d5756d5b304c9b6bef88b54b1816b328e7e6fc9254e625529a15ed4ab8", size = 338819, upload-time = "2026-05-09T13:26:46.904Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/36/7180e7f077c38108945dbbdf60fe04db681c3feb6e96419f8c6dc8723741/requests-2.34.1.tar.gz", hash = "sha256:0fc5669f2b69704449fe1552360bd2a73a54512dfd03e65529157f1513322beb", size = 142783, upload-time = "2026-05-13T19:20:24.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/5a/4a949d170476de3c04ac036b5466422fbcbf348a917d8042eedf2cac7d1b/requests-2.34.1-py3-none-any.whl", hash = "sha256:bf38a3ff993960d3dd819c08862c40b3c703306eb7c744fcd9f4ddbb95b548f0", size = 73085, upload-time = "2026-05-13T19:20:22.827Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, + { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] From 7b6fe522d1de9cf41fa8bc823506f5294d80d322 Mon Sep 17 00:00:00 2001 From: wangwenwu Date: Fri, 15 May 2026 09:25:54 +0800 Subject: [PATCH 14/44] feat: deduplicate kb insights via semantic search before write-back Before storing each new insight, embed it, retrieve the top-5 most similar existing insights, and ask the LLM to judge whether the new insight is already fully covered. Insights below the 0.7 similarity threshold bypass the LLM call entirely. On failure the check defaults to storing, so no insight is silently dropped. --- src/heta/mem/kb_writer.py | 68 +++++++++++++++++++++++++++++++++++---- src/heta/mem/prompts.py | 17 ++++++++++ 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/heta/mem/kb_writer.py b/src/heta/mem/kb_writer.py index 8c483a4..f13782a 100644 --- a/src/heta/mem/kb_writer.py +++ b/src/heta/mem/kb_writer.py @@ -13,14 +13,19 @@ from heta.mem.client import build_client, build_embedding_client, extra_body from heta.mem.db import get_connection, init_db from heta.mem.embedder import embed_text -from heta.mem.kb_store import insert_insight_embedding, insert_kb_insight +from heta.mem.kb_store import insert_insight_embedding, insert_kb_insight, search_kb_insights from heta.mem.models import KBInsight, MemoryMeta from heta.mem.paths import db_path, ensure_mem_dir -from heta.mem.prompts import KB_INSIGHT_EXTRACTION_PROMPT +from heta.mem.prompts import INSIGHT_DEDUP_PROMPT, KB_INSIGHT_EXTRACTION_PROMPT from heta.query.tools import read_page logger = logging.getLogger(__name__) +# Only invoke the LLM dedup check when semantic similarity is this high. +# Below the threshold the candidate is almost certainly a new insight. +_DEDUP_SIMILARITY_THRESHOLD = 0.7 +_DEDUP_TOP_K = 5 + def remember_kb_insights( question: str, @@ -57,6 +62,15 @@ def remember_kb_insights( continue for insight_text in insights: + # Embed first so we can run the dedup check before writing. + emb = embed_text(emb_client, emb_model, insight_text) + + similar = search_kb_insights(conn, emb, top_k=_DEDUP_TOP_K) + if similar and similar[0]["score"] >= _DEDUP_SIMILARITY_THRESHOLD: + if _is_duplicate(llm_client, llm_model, insight_text, similar, config): + logger.debug("skip duplicate insight: %.80s", insight_text) + continue + memory_id = str(uuid.uuid4()) meta = MemoryMeta( memory_id=memory_id, @@ -78,7 +92,6 @@ def remember_kb_insights( ) meta_store.insert_meta(conn, meta) insert_kb_insight(conn, insight) - emb = embed_text(emb_client, emb_model, insight_text) insert_insight_embedding(conn, memory_id, emb) total += 1 @@ -87,9 +100,41 @@ def remember_kb_insights( return total -def _extract_insights(client, model: str, question: str, page_content: str, config) -> list[str]: +def _is_duplicate( + client, + model: str, + insight_text: str, + similar: list[dict], + config: HetaConfig, +) -> bool: + """Ask the LLM whether insight_text is already covered by any of the similar insights.""" + existing_block = "\n".join( + f"[{i + 1}] {s['insight']}" for i, s in enumerate(similar) + ) + user_msg = f"New insight:\n{insight_text}\n\nExisting similar insights:\n{existing_block}" + kwargs: dict = { + "model": model, + "messages": [ + {"role": "system", "content": INSIGHT_DEDUP_PROMPT}, + {"role": "user", "content": user_msg}, + ], + "temperature": 0.0, + } + body = extra_body(config) + if body: + kwargs["extra_body"] = body + try: + raw = (client.chat.completions.create(**kwargs).choices[0].message.content or "").strip() + data = _parse_json(raw) + return bool(data.get("duplicate", False)) + except Exception: + logger.warning("dedup check failed, defaulting to store: %.80s", insight_text) + return False + + +def _extract_insights(client, model: str, question: str, page_content: str, config: HetaConfig) -> list[str]: user_msg = f"Question:\n{question}\n\nKB page content:\n{page_content}" - kwargs = { + kwargs: dict = { "model": model, "messages": [ {"role": "system", "content": KB_INSIGHT_EXTRACTION_PROMPT}, @@ -105,7 +150,7 @@ def _extract_insights(client, model: str, question: str, page_content: str, conf raw = (response.choices[0].message.content or "").strip() return _parse_insights(raw) except Exception: - logger.exception("insight extraction failed for question: %s", question[:80]) + logger.exception("insight extraction failed for question: %.80s", question) return [] @@ -121,3 +166,14 @@ def _parse_insights(raw: str) -> list[str]: except (json.JSONDecodeError, AttributeError): logger.warning("failed to parse insights response: %s", raw[:200]) return [] + + +def _parse_json(raw: str) -> dict: + text = raw.strip() + if text.startswith("```"): + lines = text.splitlines() + text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]) + try: + return json.loads(text) + except (json.JSONDecodeError, AttributeError): + return {} diff --git a/src/heta/mem/prompts.py b/src/heta/mem/prompts.py index 68a01f9..a7e06e3 100644 --- a/src/heta/mem/prompts.py +++ b/src/heta/mem/prompts.py @@ -113,6 +113,23 @@ - If the page contains nothing relevant, return {"insights": []}. """ +INSIGHT_DEDUP_PROMPT = """\ +You are a memory deduplication judge. + +Given a NEW insight and a list of EXISTING similar insights already stored in memory, +decide whether the new insight is already fully covered and would be redundant to store. + +Return STRICT JSON only. No markdown, no extra text. +Schema: {"duplicate": true} OR {"duplicate": false} + +Rules: +- Return {"duplicate": true} ONLY if an existing insight conveys the same fact or knowledge + as the new insight. A paraphrase of the same fact counts as duplicate. +- Return {"duplicate": false} if the new insight adds ANY information not present in the + existing ones, even if they are thematically related. +- When in doubt, return {"duplicate": false} — prefer storing over silently dropping. +""" + CONFLICT_JUDGE_PROMPT = """\ You are a memory conflict resolver. Given a new fact and a list of existing facts, decide which existing facts are directly contradicted by the new fact and should be deprecated. From 2afb9b10fe715e6f6660cd3a20f61c1a75bd13fc Mon Sep 17 00:00:00 2001 From: wangwenwu Date: Fri, 15 May 2026 09:29:34 +0800 Subject: [PATCH 15/44] Revert "feat: deduplicate kb insights via semantic search before write-back" This reverts commit 266caa896bf2cce29f6c49392348ebc13b4796f3. --- src/heta/mem/kb_writer.py | 68 ++++----------------------------------- src/heta/mem/prompts.py | 17 ---------- 2 files changed, 6 insertions(+), 79 deletions(-) diff --git a/src/heta/mem/kb_writer.py b/src/heta/mem/kb_writer.py index f13782a..8c483a4 100644 --- a/src/heta/mem/kb_writer.py +++ b/src/heta/mem/kb_writer.py @@ -13,19 +13,14 @@ from heta.mem.client import build_client, build_embedding_client, extra_body from heta.mem.db import get_connection, init_db from heta.mem.embedder import embed_text -from heta.mem.kb_store import insert_insight_embedding, insert_kb_insight, search_kb_insights +from heta.mem.kb_store import insert_insight_embedding, insert_kb_insight from heta.mem.models import KBInsight, MemoryMeta from heta.mem.paths import db_path, ensure_mem_dir -from heta.mem.prompts import INSIGHT_DEDUP_PROMPT, KB_INSIGHT_EXTRACTION_PROMPT +from heta.mem.prompts import KB_INSIGHT_EXTRACTION_PROMPT from heta.query.tools import read_page logger = logging.getLogger(__name__) -# Only invoke the LLM dedup check when semantic similarity is this high. -# Below the threshold the candidate is almost certainly a new insight. -_DEDUP_SIMILARITY_THRESHOLD = 0.7 -_DEDUP_TOP_K = 5 - def remember_kb_insights( question: str, @@ -62,15 +57,6 @@ def remember_kb_insights( continue for insight_text in insights: - # Embed first so we can run the dedup check before writing. - emb = embed_text(emb_client, emb_model, insight_text) - - similar = search_kb_insights(conn, emb, top_k=_DEDUP_TOP_K) - if similar and similar[0]["score"] >= _DEDUP_SIMILARITY_THRESHOLD: - if _is_duplicate(llm_client, llm_model, insight_text, similar, config): - logger.debug("skip duplicate insight: %.80s", insight_text) - continue - memory_id = str(uuid.uuid4()) meta = MemoryMeta( memory_id=memory_id, @@ -92,6 +78,7 @@ def remember_kb_insights( ) meta_store.insert_meta(conn, meta) insert_kb_insight(conn, insight) + emb = embed_text(emb_client, emb_model, insight_text) insert_insight_embedding(conn, memory_id, emb) total += 1 @@ -100,41 +87,9 @@ def remember_kb_insights( return total -def _is_duplicate( - client, - model: str, - insight_text: str, - similar: list[dict], - config: HetaConfig, -) -> bool: - """Ask the LLM whether insight_text is already covered by any of the similar insights.""" - existing_block = "\n".join( - f"[{i + 1}] {s['insight']}" for i, s in enumerate(similar) - ) - user_msg = f"New insight:\n{insight_text}\n\nExisting similar insights:\n{existing_block}" - kwargs: dict = { - "model": model, - "messages": [ - {"role": "system", "content": INSIGHT_DEDUP_PROMPT}, - {"role": "user", "content": user_msg}, - ], - "temperature": 0.0, - } - body = extra_body(config) - if body: - kwargs["extra_body"] = body - try: - raw = (client.chat.completions.create(**kwargs).choices[0].message.content or "").strip() - data = _parse_json(raw) - return bool(data.get("duplicate", False)) - except Exception: - logger.warning("dedup check failed, defaulting to store: %.80s", insight_text) - return False - - -def _extract_insights(client, model: str, question: str, page_content: str, config: HetaConfig) -> list[str]: +def _extract_insights(client, model: str, question: str, page_content: str, config) -> list[str]: user_msg = f"Question:\n{question}\n\nKB page content:\n{page_content}" - kwargs: dict = { + kwargs = { "model": model, "messages": [ {"role": "system", "content": KB_INSIGHT_EXTRACTION_PROMPT}, @@ -150,7 +105,7 @@ def _extract_insights(client, model: str, question: str, page_content: str, conf raw = (response.choices[0].message.content or "").strip() return _parse_insights(raw) except Exception: - logger.exception("insight extraction failed for question: %.80s", question) + logger.exception("insight extraction failed for question: %s", question[:80]) return [] @@ -166,14 +121,3 @@ def _parse_insights(raw: str) -> list[str]: except (json.JSONDecodeError, AttributeError): logger.warning("failed to parse insights response: %s", raw[:200]) return [] - - -def _parse_json(raw: str) -> dict: - text = raw.strip() - if text.startswith("```"): - lines = text.splitlines() - text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]) - try: - return json.loads(text) - except (json.JSONDecodeError, AttributeError): - return {} diff --git a/src/heta/mem/prompts.py b/src/heta/mem/prompts.py index a7e06e3..68a01f9 100644 --- a/src/heta/mem/prompts.py +++ b/src/heta/mem/prompts.py @@ -113,23 +113,6 @@ - If the page contains nothing relevant, return {"insights": []}. """ -INSIGHT_DEDUP_PROMPT = """\ -You are a memory deduplication judge. - -Given a NEW insight and a list of EXISTING similar insights already stored in memory, -decide whether the new insight is already fully covered and would be redundant to store. - -Return STRICT JSON only. No markdown, no extra text. -Schema: {"duplicate": true} OR {"duplicate": false} - -Rules: -- Return {"duplicate": true} ONLY if an existing insight conveys the same fact or knowledge - as the new insight. A paraphrase of the same fact counts as duplicate. -- Return {"duplicate": false} if the new insight adds ANY information not present in the - existing ones, even if they are thematically related. -- When in doubt, return {"duplicate": false} — prefer storing over silently dropping. -""" - CONFLICT_JUDGE_PROMPT = """\ You are a memory conflict resolver. Given a new fact and a list of existing facts, decide which existing facts are directly contradicted by the new fact and should be deprecated. From e5068b34af95c76415869cfc15e19d0cea7ea866 Mon Sep 17 00:00:00 2001 From: wangwenwu Date: Fri, 15 May 2026 09:29:54 +0800 Subject: [PATCH 16/44] feat: merge feature/kb-insight-dedup into main --- src/heta/mem/kb_writer.py | 68 +++++++++++++++++++++++++++++++++++---- src/heta/mem/prompts.py | 17 ++++++++++ 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/heta/mem/kb_writer.py b/src/heta/mem/kb_writer.py index 8c483a4..f13782a 100644 --- a/src/heta/mem/kb_writer.py +++ b/src/heta/mem/kb_writer.py @@ -13,14 +13,19 @@ from heta.mem.client import build_client, build_embedding_client, extra_body from heta.mem.db import get_connection, init_db from heta.mem.embedder import embed_text -from heta.mem.kb_store import insert_insight_embedding, insert_kb_insight +from heta.mem.kb_store import insert_insight_embedding, insert_kb_insight, search_kb_insights from heta.mem.models import KBInsight, MemoryMeta from heta.mem.paths import db_path, ensure_mem_dir -from heta.mem.prompts import KB_INSIGHT_EXTRACTION_PROMPT +from heta.mem.prompts import INSIGHT_DEDUP_PROMPT, KB_INSIGHT_EXTRACTION_PROMPT from heta.query.tools import read_page logger = logging.getLogger(__name__) +# Only invoke the LLM dedup check when semantic similarity is this high. +# Below the threshold the candidate is almost certainly a new insight. +_DEDUP_SIMILARITY_THRESHOLD = 0.7 +_DEDUP_TOP_K = 5 + def remember_kb_insights( question: str, @@ -57,6 +62,15 @@ def remember_kb_insights( continue for insight_text in insights: + # Embed first so we can run the dedup check before writing. + emb = embed_text(emb_client, emb_model, insight_text) + + similar = search_kb_insights(conn, emb, top_k=_DEDUP_TOP_K) + if similar and similar[0]["score"] >= _DEDUP_SIMILARITY_THRESHOLD: + if _is_duplicate(llm_client, llm_model, insight_text, similar, config): + logger.debug("skip duplicate insight: %.80s", insight_text) + continue + memory_id = str(uuid.uuid4()) meta = MemoryMeta( memory_id=memory_id, @@ -78,7 +92,6 @@ def remember_kb_insights( ) meta_store.insert_meta(conn, meta) insert_kb_insight(conn, insight) - emb = embed_text(emb_client, emb_model, insight_text) insert_insight_embedding(conn, memory_id, emb) total += 1 @@ -87,9 +100,41 @@ def remember_kb_insights( return total -def _extract_insights(client, model: str, question: str, page_content: str, config) -> list[str]: +def _is_duplicate( + client, + model: str, + insight_text: str, + similar: list[dict], + config: HetaConfig, +) -> bool: + """Ask the LLM whether insight_text is already covered by any of the similar insights.""" + existing_block = "\n".join( + f"[{i + 1}] {s['insight']}" for i, s in enumerate(similar) + ) + user_msg = f"New insight:\n{insight_text}\n\nExisting similar insights:\n{existing_block}" + kwargs: dict = { + "model": model, + "messages": [ + {"role": "system", "content": INSIGHT_DEDUP_PROMPT}, + {"role": "user", "content": user_msg}, + ], + "temperature": 0.0, + } + body = extra_body(config) + if body: + kwargs["extra_body"] = body + try: + raw = (client.chat.completions.create(**kwargs).choices[0].message.content or "").strip() + data = _parse_json(raw) + return bool(data.get("duplicate", False)) + except Exception: + logger.warning("dedup check failed, defaulting to store: %.80s", insight_text) + return False + + +def _extract_insights(client, model: str, question: str, page_content: str, config: HetaConfig) -> list[str]: user_msg = f"Question:\n{question}\n\nKB page content:\n{page_content}" - kwargs = { + kwargs: dict = { "model": model, "messages": [ {"role": "system", "content": KB_INSIGHT_EXTRACTION_PROMPT}, @@ -105,7 +150,7 @@ def _extract_insights(client, model: str, question: str, page_content: str, conf raw = (response.choices[0].message.content or "").strip() return _parse_insights(raw) except Exception: - logger.exception("insight extraction failed for question: %s", question[:80]) + logger.exception("insight extraction failed for question: %.80s", question) return [] @@ -121,3 +166,14 @@ def _parse_insights(raw: str) -> list[str]: except (json.JSONDecodeError, AttributeError): logger.warning("failed to parse insights response: %s", raw[:200]) return [] + + +def _parse_json(raw: str) -> dict: + text = raw.strip() + if text.startswith("```"): + lines = text.splitlines() + text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]) + try: + return json.loads(text) + except (json.JSONDecodeError, AttributeError): + return {} diff --git a/src/heta/mem/prompts.py b/src/heta/mem/prompts.py index 68a01f9..a7e06e3 100644 --- a/src/heta/mem/prompts.py +++ b/src/heta/mem/prompts.py @@ -113,6 +113,23 @@ - If the page contains nothing relevant, return {"insights": []}. """ +INSIGHT_DEDUP_PROMPT = """\ +You are a memory deduplication judge. + +Given a NEW insight and a list of EXISTING similar insights already stored in memory, +decide whether the new insight is already fully covered and would be redundant to store. + +Return STRICT JSON only. No markdown, no extra text. +Schema: {"duplicate": true} OR {"duplicate": false} + +Rules: +- Return {"duplicate": true} ONLY if an existing insight conveys the same fact or knowledge + as the new insight. A paraphrase of the same fact counts as duplicate. +- Return {"duplicate": false} if the new insight adds ANY information not present in the + existing ones, even if they are thematically related. +- When in doubt, return {"duplicate": false} — prefer storing over silently dropping. +""" + CONFLICT_JUDGE_PROMPT = """\ You are a memory conflict resolver. Given a new fact and a list of existing facts, decide which existing facts are directly contradicted by the new fact and should be deprecated. From 13bb2d92d8fdbed3f64190c784dfc7d2f0cdd321 Mon Sep 17 00:00:00 2001 From: 77one Date: Fri, 15 May 2026 09:35:48 +0800 Subject: [PATCH 17/44] feat: support code file insert --- src/heta/kb/code_parser.py | 375 +++++++++++++++++++++++++++++++++++++ src/heta/kb/discovery.py | 3 +- src/heta/kb/parser.py | 3 + src/heta/query/agent.py | 90 ++++++--- src/heta/query/tools.py | 26 ++- tests/test_code_parser.py | 70 +++++++ tests/test_kb_insert.py | 11 ++ tests/test_query.py | 23 ++- 8 files changed, 572 insertions(+), 29 deletions(-) create mode 100644 src/heta/kb/code_parser.py create mode 100644 tests/test_code_parser.py diff --git a/src/heta/kb/code_parser.py b/src/heta/kb/code_parser.py new file mode 100644 index 0000000..c7d6cff --- /dev/null +++ b/src/heta/kb/code_parser.py @@ -0,0 +1,375 @@ +"""Static code-file parsing for Little Heta KB inserts.""" + +from __future__ import annotations + +import ast +import json +import re +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import Any + +CODE_EXTENSIONS = { + ".py", + ".js", + ".ts", + ".tsx", + ".jsx", + ".java", + ".go", + ".rs", + ".cpp", + ".c", + ".h", + ".hpp", + ".sh", + ".sql", + ".yaml", + ".yml", + ".json", + ".toml", +} + +SMALL_CODE_LINE_LIMIT = 200 + +LANGUAGES = { + ".py": "python", + ".js": "javascript", + ".ts": "typescript", + ".tsx": "typescript", + ".jsx": "javascript", + ".java": "java", + ".go": "go", + ".rs": "rust", + ".cpp": "cpp", + ".c": "c", + ".h": "c/cpp header", + ".hpp": "cpp header", + ".sh": "shell", + ".sql": "sql", + ".yaml": "yaml", + ".yml": "yaml", + ".json": "json", + ".toml": "toml", +} + + +@dataclass(frozen=True) +class CodeSymbol: + name: str + kind: str + signature: str + start_line: int + end_line: int + summary: str + + +def parse_code_markdown(source_path: Path, archived_path: Path) -> str: + text = source_path.read_text(encoding="utf-8", errors="replace") + suffix = source_path.suffix.lower() + language = LANGUAGES.get(suffix, suffix.lstrip(".") or "text") + lines = text.splitlines() + symbols = extract_code_symbols(source_path, text) + + return build_code_markdown( + title=f"Code - {source_path.name}", + source_name=archived_path.name, + raw_path=f"../../raw/{archived_path.name}", + language=language, + line_count=len(lines), + symbols=symbols, + code=text if len(lines) <= SMALL_CODE_LINE_LIMIT else None, + ) + + +def build_code_markdown( + *, + title: str, + source_name: str, + raw_path: str, + language: str, + line_count: int, + symbols: list[CodeSymbol], + code: str | None, +) -> str: + summary = _summary(language, line_count, symbols) + body = [ + "---", + f"title: {title}", + f"sources: [{source_name}]", + f"updated: {date.today().isoformat()}", + "---", + "", + "## Summary", + summary, + "", + "## Content", + "", + f"[Raw source](<{raw_path}>)", + "", + "### File Overview", + f"- language: {language}", + f"- lines: {line_count}", + ] + if symbols: + names = ", ".join(symbol.name for symbol in symbols[:20]) + suffix = "" if len(symbols) <= 20 else f", ... ({len(symbols)} total)" + body.append(f"- symbols: {names}{suffix}") + else: + body.append("- symbols: none detected") + + if code is not None: + body.extend(["", "### Code", f"```{_fence_language(language)}", code.rstrip(), "```"]) + else: + body.extend(["", "### Symbol Index"]) + if symbols: + for symbol in symbols: + body.extend( + [ + "", + f"#### {symbol.name}", + f"Lines: {symbol.start_line}-{symbol.end_line}", + f"Type: {symbol.kind}", + ] + ) + if symbol.signature: + body.append(f"Signature: `{symbol.signature}`") + body.append(f"Summary: {symbol.summary}") + else: + body.extend(["", "#### Lines 1-" + str(line_count), "Summary: Full source is available in raw."]) + + body.extend(["", "## Source", f"- {source_name}", ""]) + return "\n".join(body) + + +def extract_code_symbols(path: Path, text: str) -> list[CodeSymbol]: + suffix = path.suffix.lower() + if suffix == ".py": + return _python_symbols(text) + if suffix in {".yaml", ".yml", ".json", ".toml"}: + return _config_symbols(suffix, text) + if suffix == ".sql": + return _sql_symbols(text) + return _regex_symbols(suffix, text) + + +def _python_symbols(text: str) -> list[CodeSymbol]: + try: + tree = ast.parse(text) + except SyntaxError: + return [] + + symbols: list[CodeSymbol] = [] + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + symbols.append( + CodeSymbol( + name=node.name, + kind="class", + signature=f"class {node.name}", + start_line=node.lineno, + end_line=getattr(node, "end_lineno", node.lineno), + summary=_doc_summary(ast.get_docstring(node), f"Defines class `{node.name}`."), + ) + ) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + prefix = "async function" if isinstance(node, ast.AsyncFunctionDef) else "function" + symbols.append( + CodeSymbol( + name=node.name, + kind=prefix, + signature=_python_signature(node), + start_line=node.lineno, + end_line=getattr(node, "end_lineno", node.lineno), + summary=_doc_summary(ast.get_docstring(node), f"Defines {prefix} `{node.name}`."), + ) + ) + return sorted(symbols, key=lambda symbol: (symbol.start_line, symbol.name)) + + +def _python_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str: + args = [arg.arg for arg in node.args.posonlyargs + node.args.args] + if node.args.vararg: + args.append("*" + node.args.vararg.arg) + args.extend(arg.arg for arg in node.args.kwonlyargs) + if node.args.kwarg: + args.append("**" + node.args.kwarg.arg) + prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def" + return f"{prefix} {node.name}({', '.join(args)})" + + +def _regex_symbols(suffix: str, text: str) -> list[CodeSymbol]: + patterns = { + ".js": [ + ("class", re.compile(r"^\s*(?:export\s+)?class\s+([A-Za-z_$][\w$]*)", re.MULTILINE)), + ("function", re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(", re.MULTILINE)), + ("function", re.compile(r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\(", re.MULTILINE)), + ], + ".ts": [], + ".tsx": [], + ".jsx": [], + ".java": [ + ("class", re.compile(r"^\s*(?:public\s+)?(?:final\s+)?class\s+([A-Za-z_]\w*)", re.MULTILINE)), + ("method", re.compile(r"^\s*(?:public|private|protected)\s+[\w<>\[\], ?]+\s+([A-Za-z_]\w*)\s*\(", re.MULTILINE)), + ], + ".go": [ + ("function", re.compile(r"^\s*func\s+(?:\([^)]+\)\s*)?([A-Za-z_]\w*)\s*\(", re.MULTILINE)), + ("type", re.compile(r"^\s*type\s+([A-Za-z_]\w*)\s+", re.MULTILINE)), + ], + ".rs": [ + ("function", re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_]\w*)\s*\(", re.MULTILINE)), + ("type", re.compile(r"^\s*(?:pub\s+)?(?:struct|enum|trait)\s+([A-Za-z_]\w*)", re.MULTILINE)), + ], + ".cpp": [], + ".c": [], + ".h": [], + ".hpp": [], + ".sh": [ + ("function", re.compile(r"^\s*(?:function\s+)?([A-Za-z_][\w-]*)\s*\(\)\s*\{?", re.MULTILINE)), + ], + } + patterns[".ts"] = patterns[".js"] + patterns[".tsx"] = patterns[".js"] + patterns[".jsx"] = patterns[".js"] + c_like = [ + ( + "function", + re.compile( + r"^\s*(?:static\s+|inline\s+|extern\s+)?[\w:*&<>\[\]\s]+\s+([A-Za-z_]\w*)\s*\([^;{}]*\)\s*\{", + re.MULTILINE, + ), + ) + ] + patterns[".cpp"] = c_like + patterns[".c"] = c_like + patterns[".h"] = c_like + patterns[".hpp"] = c_like + + lines = text.splitlines() + symbols: list[CodeSymbol] = [] + seen: set[tuple[str, int]] = set() + for kind, pattern in patterns.get(suffix, []): + for match in pattern.finditer(text): + name = match.group(1) + start_line = text[: match.start()].count("\n") + 1 + if (name, start_line) in seen: + continue + seen.add((name, start_line)) + signature = lines[start_line - 1].strip() if start_line - 1 < len(lines) else name + symbols.append( + CodeSymbol( + name=name, + kind=kind, + signature=signature.rstrip("{").strip(), + start_line=start_line, + end_line=_next_symbol_end(start_line, lines), + summary=f"Defines {kind} `{name}`.", + ) + ) + return sorted(symbols, key=lambda symbol: (symbol.start_line, symbol.name)) + + +def _config_symbols(suffix: str, text: str) -> list[CodeSymbol]: + names: list[tuple[str, int]] = [] + if suffix == ".json": + try: + data = json.loads(text) + except json.JSONDecodeError: + data = None + if isinstance(data, dict): + line_lookup = text.splitlines() + for key in data: + line = _find_key_line(str(key), line_lookup) + names.append((str(key), line)) + else: + pattern = re.compile(r"^([A-Za-z0-9_.-]+)\s*[:=]", re.MULTILINE) + names = [(match.group(1), text[: match.start()].count("\n") + 1) for match in pattern.finditer(text)] + + lines = text.splitlines() + symbols = [ + CodeSymbol( + name=name, + kind="config block", + signature=name, + start_line=line, + end_line=_next_symbol_end(line, lines), + summary=f"Configuration block `{name}`.", + ) + for name, line in names + ] + return sorted(symbols, key=lambda symbol: (symbol.start_line, symbol.name)) + + +def _sql_symbols(text: str) -> list[CodeSymbol]: + pattern = re.compile( + r"^\s*(CREATE\s+(?:TABLE|VIRTUAL\s+TABLE|INDEX|VIEW)|SELECT|INSERT|UPDATE|DELETE)\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][\w.]*)?", + re.IGNORECASE | re.MULTILINE, + ) + lines = text.splitlines() + symbols: list[CodeSymbol] = [] + for index, match in enumerate(pattern.finditer(text), start=1): + start_line = text[: match.start()].count("\n") + 1 + op = " ".join(match.group(1).upper().split()) + target = match.group(2) or f"statement-{index}" + name = f"{op} {target}" + symbols.append( + CodeSymbol( + name=name, + kind="sql statement", + signature=lines[start_line - 1].strip() if start_line - 1 < len(lines) else name, + start_line=start_line, + end_line=_next_symbol_end(start_line, lines), + summary=f"SQL statement `{name}`.", + ) + ) + return symbols + + +def _next_symbol_end(start_line: int, lines: list[str]) -> int: + return min(len(lines), start_line + 80) + + +def _find_key_line(key: str, lines: list[str]) -> int: + quoted = re.compile(rf'^\s*"{re.escape(key)}"\s*:') + plain = re.compile(rf"^\s*{re.escape(key)}\s*[:=]") + for index, line in enumerate(lines, start=1): + if quoted.search(line) or plain.search(line): + return index + return 1 + + +def _doc_summary(docstring: str | None, fallback: str) -> str: + if not docstring: + return fallback + first = " ".join(docstring.strip().splitlines()[0].split()) + return first.rstrip(".") + "." + + +def _summary(language: str, line_count: int, symbols: list[CodeSymbol]) -> str: + if symbols: + names = ", ".join(symbol.name for symbol in symbols[:8]) + suffix = "" if len(symbols) <= 8 else f", and {len(symbols) - 8} more" + return f"{language} source file with {line_count} lines. Main indexed symbols: {names}{suffix}." + return f"{language} source file with {line_count} lines. Full source is available through the raw file link." + + +def _fence_language(language: str) -> str: + return { + "python": "python", + "javascript": "javascript", + "typescript": "typescript", + "java": "java", + "go": "go", + "rust": "rust", + "cpp": "cpp", + "c": "c", + "shell": "bash", + "sql": "sql", + "yaml": "yaml", + "json": "json", + "toml": "toml", + }.get(language, "") + + +__all__ = ["CODE_EXTENSIONS", "CodeSymbol", "build_code_markdown", "extract_code_symbols", "parse_code_markdown"] diff --git a/src/heta/kb/discovery.py b/src/heta/kb/discovery.py index 7a1f8d8..6969b25 100644 --- a/src/heta/kb/discovery.py +++ b/src/heta/kb/discovery.py @@ -5,6 +5,7 @@ from pathlib import Path from heta.config.schema import HetaConfig +from heta.kb.code_parser import CODE_EXTENSIONS PLAIN_EXTENSIONS = {".md", ".markdown", ".txt"} MINERU_EXTENSIONS = {".pdf"} @@ -13,7 +14,7 @@ def supported_extensions(config: HetaConfig) -> set[str]: - extensions = set(PLAIN_EXTENSIONS) | IMAGE_EXTENSIONS | AUDIO_EXTENSIONS + extensions = set(PLAIN_EXTENSIONS) | IMAGE_EXTENSIONS | AUDIO_EXTENSIONS | CODE_EXTENSIONS if config.mineru.enable: extensions |= MINERU_EXTENSIONS return extensions diff --git a/src/heta/kb/parser.py b/src/heta/kb/parser.py index 9f6a9af..8cf8931 100644 --- a/src/heta/kb/parser.py +++ b/src/heta/kb/parser.py @@ -9,6 +9,7 @@ from heta.config.schema import HetaConfig from heta.kb.audio_parser import AUDIO_EXTENSIONS, parse_audio_markdown +from heta.kb.code_parser import CODE_EXTENSIONS, parse_code_markdown from heta.kb.image_parser import IMAGE_EXTENSIONS, parse_image_markdown from heta.kb.models import ParsedDocument from heta.kb.text import extract_title @@ -24,6 +25,8 @@ def parse_document(source_path: Path, archived_path: Path, config: HetaConfig) - markdown = parse_image_markdown(source_path, archived_path, config) elif suffix in AUDIO_EXTENSIONS: markdown = parse_audio_markdown(source_path, archived_path, config) + elif suffix in CODE_EXTENSIONS: + markdown = parse_code_markdown(source_path, archived_path) else: raise ValueError(f"Unsupported file type: {suffix}") diff --git a/src/heta/query/agent.py b/src/heta/query/agent.py index bf96796..3334123 100644 --- a/src/heta/query/agent.py +++ b/src/heta/query/agent.py @@ -16,42 +16,69 @@ format_vector_matches, read_index, read_page, + read_raw, search_vector, source_from_page_path, ) -QUERY_TOOLS = [ - { - "type": "function", - "function": { - "name": "read_page", - "description": "Read a wiki page. Valid paths: pages/*.md.", - "parameters": { - "type": "object", - "properties": {"path": {"type": "string"}}, - "required": ["path"], - "additionalProperties": False, - }, +READ_PAGE_TOOL = { + "type": "function", + "function": { + "name": "read_page", + "description": "Read a wiki page. Valid paths: pages/*.md.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + "additionalProperties": False, }, }, - { - "type": "function", - "function": { - "name": "search_vector", - "description": "Search semantic wiki chunks. Returns wiki id, page path, heading path, content, and score.", - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string"}, - "top_k": {"type": "integer", "minimum": 1, "maximum": 10}, - }, - "required": ["query"], - "additionalProperties": False, +} + +READ_RAW_TOOL = { + "type": "function", + "function": { + "name": "read_raw", + "description": "Read an original raw file referenced by a wiki page. Valid paths stay under raw/.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + "additionalProperties": False, + }, + }, +} + +SEARCH_VECTOR_TOOL = { + "type": "function", + "function": { + "name": "search_vector", + "description": "Search semantic wiki chunks. Returns wiki id, page path, heading path, content, and score.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "top_k": {"type": "integer", "minimum": 1, "maximum": 10}, }, + "required": ["query"], + "additionalProperties": False, }, }, +} + +QUERY_TOOLS = [ + READ_PAGE_TOOL, + READ_RAW_TOOL, + SEARCH_VECTOR_TOOL, +] + +QUERY_TOOLS_NO_VECTOR = [ + READ_PAGE_TOOL, + READ_RAW_TOOL, ] +RAW_SNIPPET_MAX_CHARS = 16000 + @dataclass(frozen=True) class FinalAnswer: @@ -87,7 +114,7 @@ def run_query_agent( } ] read_paths: set[str] = set() - tools = QUERY_TOOLS if config.vector_index.enable else [QUERY_TOOLS[0]] + tools = QUERY_TOOLS if config.vector_index.enable else QUERY_TOOLS_NO_VECTOR while stats.should_continue(): response = _chat_completion( @@ -176,6 +203,8 @@ def _system_prompt(vector_enabled: bool) -> str: - Treat index.md as the global map of pages, ids, paths, and summaries. - Treat semantic matches as starting evidence, not final truth. - If a chunk is relevant but incomplete, call read_page(path) for the full page. +- You may call read_raw(path) only for original raw files referenced by wiki pages. + Raw files help inspect details, but raw files must never appear in used_sources. - Follow useful [[Wiki Links]] by reading the linked pages when the index gives their paths. {vector_rule} - Stop reading when the context is enough. @@ -228,6 +257,9 @@ def _execute_tools( output = read_page(path, base_dir) if not output.startswith("error:"): read_paths.add(path.replace("\\", "/").strip("/")) + elif name == "read_raw": + path = str(arguments.get("path", "")) + output = _trim_raw_output(read_raw(path, base_dir)) elif name == "search_vector": query = str(arguments.get("query", "")) top_k = int(arguments.get("top_k") or default_top_k) @@ -240,6 +272,12 @@ def _execute_tools( return results +def _trim_raw_output(output: str) -> str: + if output.startswith("error:") or len(output) <= RAW_SNIPPET_MAX_CHARS: + return output + return output[:RAW_SNIPPET_MAX_CHARS] + "\n\n[truncated raw output]" + + def _vector_match_map(matches: list[VectorMatch]) -> dict[tuple[str, str], VectorMatch]: return {(_normalize_candidate_path(match.path), match.heading_path): match for match in matches} diff --git a/src/heta/query/tools.py b/src/heta/query/tools.py index da31cee..14f0841 100644 --- a/src/heta/query/tools.py +++ b/src/heta/query/tools.py @@ -31,6 +31,19 @@ def read_page(path: str, base_dir: Path | None = None) -> str: return f"error: {exc}" +def read_raw(path: str, base_dir: Path | None = None) -> str: + try: + normalized = normalize_raw_path(path) + full = _resolve_safe(paths.raw_dir(base_dir), normalized) + if not full.exists(): + return f"error: raw/{normalized} does not exist" + if not full.is_file(): + return f"error: raw/{normalized} is not a file" + return full.read_text(encoding="utf-8", errors="replace") + except Exception as exc: + return f"error: {exc}" + + def search_vector( query: str, config: HetaConfig, @@ -74,6 +87,18 @@ def normalize_page_path(path: str) -> str: raise ValueError(f"path must be pages/*.md, got: {path!r}") +def normalize_raw_path(path: str) -> str: + normalized = path.replace("\\", "/").strip() + if "/raw/" in normalized: + normalized = normalized.split("/raw/", 1)[1] + elif normalized.startswith("raw/"): + normalized = normalized[4:] + normalized = normalized.strip("/") + if not normalized or normalized.startswith("../") or "/../" in normalized: + raise ValueError(f"path must stay within raw/, got: {path!r}") + return normalized + + def wiki_id_from_page_name(page_name: str) -> int | None: match = PAGE_ID_RE.match(page_name) if match is None: @@ -116,4 +141,3 @@ def _resolve_safe(root_dir: Path, normalized: str) -> Path: def _frontmatter_value(text: str, key: str) -> str | None: match = re.search(rf"^{re.escape(key)}:\s*(.+)$", text, flags=re.MULTILINE) return match.group(1).strip() if match else None - diff --git a/tests/test_code_parser.py b/tests/test_code_parser.py new file mode 100644 index 0000000..454c129 --- /dev/null +++ b/tests/test_code_parser.py @@ -0,0 +1,70 @@ +from pathlib import Path + +from heta.config.schema import HetaConfig, InsertPlanningConfig, LLMConfig, MinerUConfig, VectorIndexConfig +from heta.kb.code_parser import extract_code_symbols, parse_code_markdown +from heta.kb.parser import parse_document +from heta.kb.text import extract_title + + +def _config() -> HetaConfig: + return HetaConfig( + version=1, + llm=LLMConfig(provider="qwen", api_key="sk-test"), + mineru=MinerUConfig.disabled(), + vector_index=VectorIndexConfig(enable=False), + insert_planning=InsertPlanningConfig.enabled(), + ) + + +def test_parse_code_markdown_keeps_small_code_inline(tmp_path: Path) -> None: + source = tmp_path / "vector_index.py" + archived = tmp_path / "2026-05-15_vector_index.py" + source.write_text( + 'def search_wiki_vector_index(query, config):\n """Search semantic wiki chunks."""\n return []\n', + encoding="utf-8", + ) + archived.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + + markdown = parse_code_markdown(source, archived) + + assert extract_title(markdown, "fallback") == "Code - vector_index.py" + assert "[Raw source](<../../raw/2026-05-15_vector_index.py>)" in markdown + assert "### Code" in markdown + assert "def search_wiki_vector_index" in markdown + + +def test_parse_code_markdown_uses_symbol_index_for_large_code(tmp_path: Path) -> None: + source = tmp_path / "service.py" + archived = tmp_path / "2026-05-15_service.py" + body = "\n".join(["class MemoryService:", " \"\"\"Coordinates memory writes.\"\"\"", " pass", *["x = 1"] * 220]) + source.write_text(body, encoding="utf-8") + archived.write_text(body, encoding="utf-8") + + markdown = parse_code_markdown(source, archived) + + assert "### Symbol Index" in markdown + assert "#### MemoryService" in markdown + assert "Lines: 1-" in markdown + assert "Coordinates memory writes." in markdown + assert "### Code" not in markdown + + +def test_extract_code_symbols_handles_config_and_sql() -> None: + yaml_symbols = extract_code_symbols(Path("heta.yaml"), "vector_index:\n enable: true\nllm:\n provider: qwen\n") + sql_symbols = extract_code_symbols(Path("schema.sql"), "CREATE TABLE wiki_chunks (id integer);\nSELECT * FROM wiki_chunks;\n") + + assert [symbol.name for symbol in yaml_symbols] == ["vector_index", "llm"] + assert sql_symbols[0].name == "CREATE TABLE wiki_chunks" + + +def test_parse_document_accepts_code_branch(tmp_path: Path) -> None: + source = tmp_path / "tool.ts" + archived = tmp_path / "2026-05-15_tool.ts" + source.write_text("export function runTool() { return true; }\n", encoding="utf-8") + archived.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + + document = parse_document(source, archived, _config()) + + assert document.title == "Code - tool.ts" + assert document.metadata["extension"] == ".ts" + assert "language: typescript" in document.markdown_content diff --git a/tests/test_kb_insert.py b/tests/test_kb_insert.py index cc6595a..807e1e0 100644 --- a/tests/test_kb_insert.py +++ b/tests/test_kb_insert.py @@ -158,6 +158,17 @@ def test_collect_insert_files_accepts_audio_and_video(tmp_path: Path) -> None: assert files == [audio, video] +def test_collect_insert_files_accepts_code_but_not_html(tmp_path: Path) -> None: + code = tmp_path / "module.py" + html = tmp_path / "index.html" + code.write_text("def run():\n pass\n", encoding="utf-8") + html.write_text("
", encoding="utf-8") + + files = collect_insert_files([tmp_path], _config()) + + assert files == [code] + + def test_collect_directory_skips_workspace(tmp_path: Path) -> None: source = tmp_path / "a.md" workspace_file = tmp_path / "workspace" / "kb" / "wiki" / "pages" / "old.md" diff --git a/tests/test_query.py b/tests/test_query.py index 1136715..9d5d965 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -8,7 +8,7 @@ from heta.query.agent import _parse_final_answer, _vector_match_map from heta.query.models import QueryResult, QuerySource, VectorMatch from heta.query.pipeline import run_wiki_query -from heta.query.tools import format_vector_matches, read_page, source_from_page_path +from heta.query.tools import format_vector_matches, read_page, read_raw, source_from_page_path def _config(vector_enabled: bool = False) -> HetaConfig: @@ -53,6 +53,16 @@ def test_read_page_is_limited_to_pages(tmp_path: Path) -> None: assert read_page("index.md", tmp_path).startswith("error:") +def test_read_raw_is_limited_to_raw_directory(tmp_path: Path) -> None: + raw = paths.raw_dir(tmp_path) + raw.mkdir(parents=True) + (raw / "module.py").write_text("def run():\n return True\n", encoding="utf-8") + + assert "def run" in read_raw("raw/module.py", tmp_path) + assert "def run" in read_raw("../../raw/module.py", tmp_path) + assert read_raw("../heta.yaml", tmp_path).startswith("error:") + + def test_source_from_page_path_reads_frontmatter_and_wiki_id(tmp_path: Path) -> None: page = paths.pages_dir(tmp_path) / "12-hetagen.md" page.parent.mkdir(parents=True) @@ -112,6 +122,17 @@ def test_query_sources_accept_read_pages_without_vector_heading(tmp_path: Path) assert final.sources == [QuerySource(10, "Audio", "pages/10-audio.md")] +def test_query_sources_reject_raw_used_sources(tmp_path: Path) -> None: + final = _parse_final_answer( + text='{"answer": "Raw helped.", "used_sources": [{"path": "raw/module.py"}]}', + read_paths=set(), + vector_matches={}, + base_dir=tmp_path, + ) + + assert final.sources == [] + + def test_format_vector_matches_includes_chunk_identity() -> None: text = format_vector_matches( [ From 8904673affa8e362b03f2f816cff3ca5b6c8fa79 Mon Sep 17 00:00:00 2001 From: 77one Date: Fri, 15 May 2026 09:46:59 +0800 Subject: [PATCH 18/44] fix: preserve code raw source links --- src/heta/kb/insert.py | 32 ++++++++++++++++++++++++++++---- tests/test_kb_insert.py | 26 ++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/heta/kb/insert.py b/src/heta/kb/insert.py index 0a6b1f1..f787fc0 100644 --- a/src/heta/kb/insert.py +++ b/src/heta/kb/insert.py @@ -9,8 +9,9 @@ from heta.config.schema import HetaConfig from heta.kb.agent import run_merge_agent +from heta.kb.code_parser import CODE_EXTENSIONS from heta.kb.discovery import collect_insert_files -from heta.kb.models import InsertProgress, InsertResult, ParsedDocument +from heta.kb.models import FileChange, InsertProgress, InsertResult, ParsedDocument from heta.kb.parser import parse_document from heta.kb.pdf_plan import plan_insert_files from heta.kb.store import commit_wiki, ensure_wiki_layout, reset_wiki @@ -84,10 +85,14 @@ def insert_paths( raise RuntimeError(f"Agent completed without changing the wiki for: {document.source_name}") normalize_result = normalize_wiki_pages(working_wiki) repair_broken_wiki_links(working_wiki) + normalized_added = apply_path_map(agent_result["added"], normalize_result.path_map) + normalized_updated = apply_path_map(agent_result["updated"], normalize_result.path_map) + normalized_deleted = apply_path_map(agent_result["deleted"], normalize_result.path_map) + _ensure_code_raw_links(working_wiki, document, [*normalized_added, *normalized_updated]) validate_wiki(working_wiki) - added.extend(apply_path_map(agent_result["added"], normalize_result.path_map)) - updated.extend(apply_path_map(agent_result["updated"], normalize_result.path_map)) - deleted.extend(apply_path_map(agent_result["deleted"], normalize_result.path_map)) + added.extend(normalized_added) + updated.extend(normalized_updated) + deleted.extend(normalized_deleted) _emit_progress( on_progress, "merge", @@ -142,6 +147,25 @@ def _merge_percent(done: int, total: int) -> int: return min(99, 1 + int(done / total * 98)) +def _ensure_code_raw_links(wiki_root: Path, document: ParsedDocument, changes: list[FileChange]) -> None: + if document.metadata.get("extension") not in CODE_EXTENSIONS: + return + raw_link = f"[Raw source](<../../raw/{document.source_name}>)" + for change in changes: + if not change.path.startswith("pages/") or not change.path.endswith(".md"): + continue + page = wiki_root / change.path + if not page.exists(): + continue + text = page.read_text(encoding="utf-8") + if raw_link in text: + continue + if "## Content" not in text: + continue + updated = text.replace("## Content\n", f"## Content\n\n{raw_link}\n", 1) + page.write_text(updated, encoding="utf-8") + + def _emit_progress( callback: Callable[[InsertProgress], None] | None, phase: str, diff --git a/tests/test_kb_insert.py b/tests/test_kb_insert.py index 807e1e0..d48ffdf 100644 --- a/tests/test_kb_insert.py +++ b/tests/test_kb_insert.py @@ -4,8 +4,8 @@ from heta.config.schema import InsertPlanningConfig, HetaConfig, LLMConfig, MinerUConfig, VectorIndexConfig from heta.kb.discovery import collect_insert_files -from heta.kb.models import FileChange -from heta.kb.insert import insert_paths +from heta.kb.models import FileChange, ParsedDocument +from heta.kb.insert import _ensure_code_raw_links, insert_paths from heta.kb.text import frontmatter_page, slugify, summarize from heta.kb.wiki import normalize_wiki_pages, repair_broken_wiki_links @@ -130,6 +130,28 @@ def test_insert_multiple_files_runs_agent_sequentially(monkeypatch, tmp_path: Pa assert progress[-1].phase == "done" +def test_ensure_code_raw_links_restores_agent_dropped_raw_link(tmp_path: Path) -> None: + wiki = tmp_path / "wiki" + page = wiki / "pages" / "1-code-demo.md" + page.parent.mkdir(parents=True) + page.write_text( + frontmatter_page("Code - demo.py", "2026-05-15_demo.py", "Summary.", "### File Overview\n- language: python"), + encoding="utf-8", + ) + document = ParsedDocument( + source_path=tmp_path / "demo.py", + archived_path=tmp_path / "raw" / "2026-05-15_demo.py", + title="Code - demo.py", + markdown_content="", + source_name="2026-05-15_demo.py", + metadata={"extension": ".py"}, + ) + + _ensure_code_raw_links(wiki, document, [FileChange("added", "Code - demo.py", "pages/1-code-demo.md")]) + + assert "[Raw source](<../../raw/2026-05-15_demo.py>)" in page.read_text(encoding="utf-8") + + def test_pdf_requires_mineru_when_disabled(tmp_path: Path) -> None: source = tmp_path / "paper.pdf" source.write_bytes(b"%PDF") From fe81e0de96226aa47a4e9f126991f3a50ba791b5 Mon Sep 17 00:00:00 2001 From: 77one Date: Fri, 15 May 2026 09:52:40 +0800 Subject: [PATCH 19/44] fix: preserve code symbol signatures --- src/heta/kb/code_parser.py | 2 +- tests/test_code_parser.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/heta/kb/code_parser.py b/src/heta/kb/code_parser.py index c7d6cff..b2e46fa 100644 --- a/src/heta/kb/code_parser.py +++ b/src/heta/kb/code_parser.py @@ -252,7 +252,7 @@ def _regex_symbols(suffix: str, text: str) -> list[CodeSymbol]: for kind, pattern in patterns.get(suffix, []): for match in pattern.finditer(text): name = match.group(1) - start_line = text[: match.start()].count("\n") + 1 + start_line = text[: match.start(1)].count("\n") + 1 if (name, start_line) in seen: continue seen.add((name, start_line)) diff --git a/tests/test_code_parser.py b/tests/test_code_parser.py index 454c129..49325ab 100644 --- a/tests/test_code_parser.py +++ b/tests/test_code_parser.py @@ -57,6 +57,20 @@ def test_extract_code_symbols_handles_config_and_sql() -> None: assert sql_symbols[0].name == "CREATE TABLE wiki_chunks" +def test_extract_code_symbols_keeps_regex_language_signatures() -> None: + cases = [ + (Path("sample.go"), "type QueryService struct{}\n\nfunc SearchWiki(query string) string {\n return query\n}\n", "SearchWiki", "func SearchWiki"), + (Path("sample.rs"), "pub struct QueryService;\n\npub fn search_wiki(query: &str) -> &str {\n query\n}\n", "search_wiki", "pub fn search_wiki"), + (Path("sample.js"), "export function runQuery(query) {\n return query;\n}\n", "runQuery", "export function runQuery"), + (Path("sample.ts"), "export function formatAnswer(result: QueryResult) {\n return result.answer;\n}\n", "formatAnswer", "export function formatAnswer"), + ] + + for path, text, name, signature_prefix in cases: + symbols = extract_code_symbols(path, text) + symbol = next(symbol for symbol in symbols if symbol.name == name) + assert symbol.signature.startswith(signature_prefix) + + def test_parse_document_accepts_code_branch(tmp_path: Path) -> None: source = tmp_path / "tool.ts" archived = tmp_path / "2026-05-15_tool.ts" From 61bda57d3a79b5b84205bdd16c5267eb668d84c0 Mon Sep 17 00:00:00 2001 From: 77one Date: Fri, 15 May 2026 10:12:21 +0800 Subject: [PATCH 20/44] fix: retry invalid query json answers --- src/heta/query/agent.py | 21 ++++++++++++++++++++- tests/test_query.py | 13 +++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/heta/query/agent.py b/src/heta/query/agent.py index 3334123..4d88764 100644 --- a/src/heta/query/agent.py +++ b/src/heta/query/agent.py @@ -84,6 +84,7 @@ class FinalAnswer: answer: str sources: list[QuerySource] + valid_json: bool = True def run_query_agent( @@ -135,6 +136,24 @@ def run_query_agent( vector_matches=vector_matches, base_dir=base_dir, ) + if not final_answer.valid_json: + messages.append( + { + "role": "assistant", + "content": message.content or "", + } + ) + messages.append( + { + "role": "user", + "content": ( + "Your previous response was not valid JSON. Return exactly one valid JSON object now, " + "with keys answer and used_sources. Do not include Markdown fences or text outside JSON." + ), + } + ) + stats.record("retry final JSON", response.usage) + continue stats.record_completion(response.usage) return QueryResult( answer=final_answer.answer, @@ -291,7 +310,7 @@ def _parse_final_answer( ) -> FinalAnswer: data = _extract_json_object(text) if data is None: - return FinalAnswer(answer=text, sources=[]) + return FinalAnswer(answer=text, sources=[], valid_json=False) answer = data.get("answer") used_sources = data.get("used_sources") diff --git a/tests/test_query.py b/tests/test_query.py index 9d5d965..51396d9 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -133,6 +133,19 @@ def test_query_sources_reject_raw_used_sources(tmp_path: Path) -> None: assert final.sources == [] +def test_parse_final_answer_marks_non_json_response_invalid(tmp_path: Path) -> None: + final = _parse_final_answer( + text="This is not JSON.", + read_paths=set(), + vector_matches={}, + base_dir=tmp_path, + ) + + assert final.answer == "This is not JSON." + assert final.sources == [] + assert not final.valid_json + + def test_format_vector_matches_includes_chunk_identity() -> None: text = format_vector_matches( [ From b848a321e8f062787b4e673a8b5675cfca2c7a3e Mon Sep 17 00:00:00 2001 From: wangwenwu Date: Fri, 15 May 2026 10:28:55 +0800 Subject: [PATCH 21/44] feat: merge feature/invalidate-kb-memory into main --- src/heta/cli/clean.py | 1 + src/heta/cli/insert.py | 3 + src/heta/kb/clean.py | 5 ++ src/heta/kb/insert.py | 5 ++ src/heta/kb/models.py | 1 + src/heta/mem/kb_invalidate.py | 95 ++++++++++++++++++++++++++ tests/test_kb_invalidate.py | 122 ++++++++++++++++++++++++++++++++++ 7 files changed, 232 insertions(+) create mode 100644 src/heta/mem/kb_invalidate.py create mode 100644 tests/test_kb_invalidate.py diff --git a/src/heta/cli/clean.py b/src/heta/cli/clean.py index 3b89f18..6b2d16d 100644 --- a/src/heta/cli/clean.py +++ b/src/heta/cli/clean.py @@ -86,6 +86,7 @@ def _show_result(summary: CleanSummary) -> None: console.print(f"[{OK}]✓[/] Clean completed.") console.print(f"[{MUTED}]pages deleted:[/] {summary.deleted_pages}") console.print(f"[{MUTED}]vector files deleted:[/] {summary.deleted_vector_files}") + console.print(f"[{MUTED}]invalidated memories:[/] {summary.invalidated_memories}") if summary.commit_id: console.print(f"[{MUTED}]wiki commit:[/] [bold {HETA}]{summary.commit_id}[/]") else: diff --git a/src/heta/cli/insert.py b/src/heta/cli/insert.py index 25d5721..5992aa7 100644 --- a/src/heta/cli/insert.py +++ b/src/heta/cli/insert.py @@ -131,6 +131,9 @@ def _show_result(result) -> None: if result.planned_pdf_parts: console.print(f"[{MUTED}]pdf parts:[/] {result.planned_pdf_parts}") + if result.invalidated_memories: + console.print(f"[{MUTED}]invalidated memories:[/] {result.invalidated_memories}") + def _insert_progress() -> Progress: return Progress( diff --git a/src/heta/kb/clean.py b/src/heta/kb/clean.py index ff9782a..9de483c 100644 --- a/src/heta/kb/clean.py +++ b/src/heta/kb/clean.py @@ -14,6 +14,7 @@ class CleanSummary: deleted_pages: int deleted_vector_files: int commit_id: str | None + invalidated_memories: int = 0 def clean_knowledge_base(*, base_dir: Path | None = None) -> CleanSummary: @@ -26,10 +27,14 @@ def clean_knowledge_base(*, base_dir: Path | None = None) -> CleanSummary: deleted_vector_files = _clear_vector_db(base_dir) commit_id = commit_wiki("chore: clean wiki knowledge base", base_dir) + from heta.mem.kb_invalidate import invalidate_all + invalidated = invalidate_all() + return CleanSummary( deleted_pages=deleted_pages, deleted_vector_files=deleted_vector_files, commit_id=commit_id, + invalidated_memories=invalidated, ) diff --git a/src/heta/kb/insert.py b/src/heta/kb/insert.py index f787fc0..497f25b 100644 --- a/src/heta/kb/insert.py +++ b/src/heta/kb/insert.py @@ -122,6 +122,10 @@ def insert_paths( except Exception: pass cleanup_working_copy(task_id, base_dir) + + from heta.mem.kb_invalidate import invalidate_by_paths + invalidated = invalidate_by_paths(c.path for c in (*updated, *deleted)) + _emit_progress(on_progress, "done", 100, total_documents, total_documents, "insert completed") return InsertResult( @@ -131,6 +135,7 @@ def insert_paths( deleted=deleted, raw_files=raw_files, planned_pdf_parts=sum(plan.parts for plan in pdf_plans if plan.enabled), + invalidated_memories=invalidated, ) except BaseException: for raw in raw_files: diff --git a/src/heta/kb/models.py b/src/heta/kb/models.py index c65a221..437927f 100644 --- a/src/heta/kb/models.py +++ b/src/heta/kb/models.py @@ -31,6 +31,7 @@ class InsertResult: deleted: list[FileChange] raw_files: list[Path] planned_pdf_parts: int = 0 + invalidated_memories: int = 0 @dataclass(frozen=True) diff --git a/src/heta/mem/kb_invalidate.py b/src/heta/mem/kb_invalidate.py new file mode 100644 index 0000000..dbec6bd --- /dev/null +++ b/src/heta/mem/kb_invalidate.py @@ -0,0 +1,95 @@ +"""Invalidate kb_insight memories whose source wiki pages changed.""" + +from __future__ import annotations + +import logging +import sqlite3 +from collections.abc import Iterable + +from heta.mem.db import get_connection, init_db +from heta.mem.paths import db_path + +logger = logging.getLogger(__name__) + + +def invalidate_by_paths(paths: Iterable[str]) -> int: + """Delete kb_insight memories whose source_path is in `paths`. Returns count deleted. + + Silently returns 0 when the memory DB does not exist yet, so KB operations + succeed even if the user never initialised memory. + """ + path_list = [p for p in paths if p] + if not path_list: + return 0 + if not db_path().exists(): + return 0 + try: + conn = get_connection(db_path(), with_vec=True) + except Exception: + logger.warning("memory DB open failed; skip kb_insight invalidation", exc_info=True) + return 0 + try: + init_db(conn) + return delete_insights_by_paths(conn, path_list) + except Exception: + logger.warning("kb_insight invalidation failed", exc_info=True) + return 0 + finally: + conn.close() + + +def invalidate_all() -> int: + """Delete every kb_insight memory. Returns count deleted. + + Silently returns 0 when the memory DB does not exist yet. + """ + if not db_path().exists(): + return 0 + try: + conn = get_connection(db_path(), with_vec=True) + except Exception: + logger.warning("memory DB open failed; skip kb_insight invalidation", exc_info=True) + return 0 + try: + init_db(conn) + return delete_all_insights(conn) + except Exception: + logger.warning("kb_insight invalidation (all) failed", exc_info=True) + return 0 + finally: + conn.close() + + +def delete_insights_by_paths(conn: sqlite3.Connection, paths: list[str]) -> int: + """Connection-level helper. Exposed for tests and callers with an open conn.""" + if not paths: + return 0 + placeholders = ",".join("?" for _ in paths) + ids = [ + r[0] + for r in conn.execute( + f"SELECT memory_id FROM kb_insight WHERE source_path IN ({placeholders})", + paths, + ).fetchall() + ] + if not ids: + return 0 + id_placeholders = ",".join("?" for _ in ids) + # vec0 virtual table does not honour FK cascade; delete explicitly. + conn.execute(f"DELETE FROM kb_insight_vec WHERE memory_id IN ({id_placeholders})", ids) + # memory_meta delete cascades to kb_insight via ON DELETE CASCADE. + conn.execute(f"DELETE FROM memory_meta WHERE memory_id IN ({id_placeholders})", ids) + conn.commit() + return len(ids) + + +def delete_all_insights(conn: sqlite3.Connection) -> int: + """Connection-level helper to wipe all kb_insight rows. Exposed for tests.""" + ids = [r[0] for r in conn.execute("SELECT memory_id FROM kb_insight").fetchall()] + if not ids: + return 0 + conn.execute("DELETE FROM kb_insight_vec") + placeholders = ",".join("?" for _ in ids) + conn.execute(f"DELETE FROM memory_meta WHERE memory_id IN ({placeholders})", ids) + conn.commit() + return len(ids) diff --git a/tests/test_kb_invalidate.py b/tests/test_kb_invalidate.py new file mode 100644 index 0000000..9d95241 --- /dev/null +++ b/tests/test_kb_invalidate.py @@ -0,0 +1,122 @@ +"""Tests for heta.mem.kb_invalidate.""" + +from __future__ import annotations + +import time +import uuid +from pathlib import Path + +import pytest + +from heta.mem.db import get_connection, init_db +from heta.mem.kb_invalidate import delete_all_insights, delete_insights_by_paths +from heta.mem.kb_store import insert_insight_embedding, insert_kb_insight +from heta.mem.meta_store import insert_meta +from heta.mem.models import KBInsight, MemoryMeta + + +@pytest.fixture() +def conn(tmp_path: Path): + db = tmp_path / "test_mem.sqlite3" + c = get_connection(db, with_vec=True) + init_db(c) + yield c + c.close() + + +def _now() -> int: + return int(time.time()) + + +def _insert_insight(conn, source_path: str, insight_text: str = "fact") -> str: + mid = str(uuid.uuid4()) + insert_meta(conn, MemoryMeta( + memory_id=mid, memory_type="kb_insight", session_id=None, + origin="kb_insight", created_at=_now(), last_access_at=_now(), + )) + insert_kb_insight(conn, KBInsight( + memory_id=mid, insight=insight_text, question="q", + source_path=source_path, wiki_id=None, heading_path=None, + created_at=_now(), + )) + # 1024-dim float embedding (matches EMBEDDING_DIM) + from heta.mem.client import EMBEDDING_DIM + insert_insight_embedding(conn, mid, [0.0] * EMBEDDING_DIM) + conn.commit() + return mid + + +def _count(conn, table: str) -> int: + return conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + + +# ── tests ───────────────────────────────────────────────────────────────────── + + +def test_delete_by_paths_removes_matching_rows(conn): + _insert_insight(conn, "pages/1-foo.md") + _insert_insight(conn, "pages/1-foo.md") # 2 insights on same page + _insert_insight(conn, "pages/2-bar.md") + + deleted = delete_insights_by_paths(conn, ["pages/1-foo.md"]) + + assert deleted == 2 + assert _count(conn, "kb_insight") == 1 + assert _count(conn, "kb_insight_vec") == 1 + assert _count(conn, "memory_meta") == 1 + + +def test_delete_by_paths_leaves_other_pages_untouched(conn): + _insert_insight(conn, "pages/1-foo.md") + bar_id = _insert_insight(conn, "pages/2-bar.md") + + delete_insights_by_paths(conn, ["pages/1-foo.md"]) + + rows = conn.execute("SELECT memory_id FROM kb_insight").fetchall() + assert [r[0] for r in rows] == [bar_id] + + +def test_delete_by_paths_empty_input(conn): + _insert_insight(conn, "pages/1-foo.md") + assert delete_insights_by_paths(conn, []) == 0 + assert _count(conn, "kb_insight") == 1 + + +def test_delete_by_paths_no_match(conn): + _insert_insight(conn, "pages/1-foo.md") + assert delete_insights_by_paths(conn, ["pages/does-not-exist.md"]) == 0 + assert _count(conn, "kb_insight") == 1 + + +def test_delete_all_clears_everything(conn): + _insert_insight(conn, "pages/1-foo.md") + _insert_insight(conn, "pages/2-bar.md") + _insert_insight(conn, "pages/3-baz.md") + + deleted = delete_all_insights(conn) + + assert deleted == 3 + assert _count(conn, "kb_insight") == 0 + assert _count(conn, "kb_insight_vec") == 0 + assert _count(conn, "memory_meta") == 0 + + +def test_delete_all_on_empty_db_returns_zero(conn): + assert delete_all_insights(conn) == 0 + + +def test_delete_by_paths_preserves_other_memory_types(conn): + """Deleting kb_insight by path must not touch L1/L2/etc.""" + _insert_insight(conn, "pages/1-foo.md") + # an unrelated memory_meta row (e.g. L2) + other = str(uuid.uuid4()) + insert_meta(conn, MemoryMeta( + memory_id=other, memory_type="L2", session_id=None, + origin="extracted", created_at=_now(), last_access_at=_now(), + )) + + delete_insights_by_paths(conn, ["pages/1-foo.md"]) + + assert _count(conn, "memory_meta") == 1 + remaining = conn.execute("SELECT memory_id FROM memory_meta").fetchone() + assert remaining[0] == other From dc2625660554813182dc2dca43301cc7fabd07d1 Mon Sep 17 00:00:00 2001 From: wangwenwu Date: Fri, 15 May 2026 10:28:55 +0800 Subject: [PATCH 22/44] feat: merge feature/mem-show-insights into main --- src/heta/cli/__init__.py | 2 + src/heta/cli/mem_show.py | 140 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 src/heta/cli/mem_show.py diff --git a/src/heta/cli/__init__.py b/src/heta/cli/__init__.py index 3c633f8..ca5ff46 100644 --- a/src/heta/cli/__init__.py +++ b/src/heta/cli/__init__.py @@ -7,6 +7,7 @@ from heta.cli.ask import ask_command from heta.cli.clean import clean_command from heta.cli.mem_clean import mem_clean_command +from heta.cli.mem_show import app as mem_show_app from heta.cli import init as init_module from heta.cli.init import interactive_init from heta.cli.insert import insert_command @@ -50,3 +51,4 @@ def init_command() -> None: app.command("status")(status_command) app.add_typer(insert_planning_app) app.add_typer(vector_app) +app.add_typer(mem_show_app) diff --git a/src/heta/cli/mem_show.py b/src/heta/cli/mem_show.py new file mode 100644 index 0000000..6d89ca9 --- /dev/null +++ b/src/heta/cli/mem_show.py @@ -0,0 +1,140 @@ +"""`heta mem-show` commands — inspect stored memories.""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime + +import typer +from rich.console import Console +from rich.table import Table + +from heta.mem.db import get_connection, init_db +from heta.mem.paths import db_path + +console = Console() + +HETA = "rgb(52,144,220)" +MUTED = "rgb(126,146,158)" +WARN = "rgb(238,183,74)" + +app = typer.Typer( + name="mem-show", + help="Inspect stored memory contents.", + no_args_is_help=True, + rich_markup_mode="rich", +) + + +@app.command("insights") +def insights_command( + source: str | None = typer.Option(None, "--source", "-s", help="Filter by source_path substring (e.g. 'pages/1-foo.md')."), + question: str | None = typer.Option(None, "--question", "-q", help="Filter by question substring."), + limit: int = typer.Option(50, "--limit", "-n", help="Max rows to show."), + full: bool = typer.Option(False, "--full", "-f", help="Show full insight text (no truncation)."), +) -> None: + """List stored kb_insight memories, newest first.""" + if not db_path().exists(): + console.print(f"[{WARN}]?[/] Memory DB does not exist yet.") + console.print(f"[{MUTED}] Run `heta ask` at least once to populate it.[/]") + raise typer.Exit(0) + + conn = get_connection(db_path(), with_vec=True) + init_db(conn) + try: + rows = _fetch_insights(conn, source=source, question=question, limit=limit) + finally: + conn.close() + + if not rows: + console.print(f"[{MUTED}]No insights matched.[/]") + return + + total = _count_total(source=source, question=question) + table = Table( + title=f"kb_insights ({len(rows)} of {total} shown)", + show_lines=not full, + border_style=HETA, + ) + table.add_column("#", style="dim", justify="right", no_wrap=True) + table.add_column("created", style=MUTED, no_wrap=True) + table.add_column("source", style=MUTED) + table.add_column("question", style=MUTED) + table.add_column("insight") + + for i, row in enumerate(rows, 1): + insight_text = row["insight"] if full else _truncate(row["insight"], 140) + question_text = row["question"] or "" + if not full: + question_text = _truncate(question_text, 50) + table.add_row( + str(i), + _format_ts(row["created_at"]), + row["source_path"] or "", + question_text, + insight_text, + ) + console.print(table) + + +def _fetch_insights( + conn: sqlite3.Connection, + *, + source: str | None, + question: str | None, + limit: int, +) -> list[sqlite3.Row]: + where, params = _build_where(source=source, question=question) + sql = f""" + SELECT i.insight, i.question, i.source_path, i.created_at + FROM kb_insight i + JOIN memory_meta m ON m.memory_id = i.memory_id + WHERE m.status = 'active' {where} + ORDER BY i.created_at DESC + LIMIT ? + """ + return conn.execute(sql, (*params, max(1, limit))).fetchall() + + +def _count_total(*, source: str | None, question: str | None) -> int: + conn = get_connection(db_path(), with_vec=True) + init_db(conn) + try: + where, params = _build_where(source=source, question=question) + row = conn.execute( + f"SELECT COUNT(*) FROM kb_insight i JOIN memory_meta m ON m.memory_id = i.memory_id " + f"WHERE m.status = 'active' {where}", + params, + ).fetchone() + return int(row[0]) + finally: + conn.close() + + +def _build_where(*, source: str | None, question: str | None) -> tuple[str, list]: + clauses: list[str] = [] + params: list = [] + if source: + clauses.append("AND i.source_path LIKE ?") + params.append(f"%{source}%") + if question: + clauses.append("AND i.question LIKE ?") + params.append(f"%{question}%") + return " ".join(clauses), params + + +def _truncate(text: str, max_len: int) -> str: + if text is None: + return "" + if len(text) <= max_len: + return text + return text[: max_len - 1] + "…" + + +def _format_ts(ts: int | None) -> str: + if not ts: + return "" + return datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M") + + +__all__ = ["app"] From 06860bfb9aaf99e26d52627273d2ae2fedf5976b Mon Sep 17 00:00:00 2001 From: 77one Date: Fri, 15 May 2026 11:12:25 +0800 Subject: [PATCH 23/44] feat: support html insert parsing --- pyproject.toml | 1 + src/heta/kb/discovery.py | 3 +- src/heta/kb/html_parser.py | 325 +++++++++++++++++++++++++++++++++++++ src/heta/kb/parser.py | 3 + tests/test_html_parser.py | 97 +++++++++++ tests/test_kb_insert.py | 4 +- uv.lock | 24 +++ 7 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 src/heta/kb/html_parser.py create mode 100644 tests/test_html_parser.py diff --git a/pyproject.toml b/pyproject.toml index 1bd7cb7..503a3ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "openai>=1.0.0", "sqlite-vec>=0.1.6", "pypdf>=4.0.0", + "beautifulsoup4>=4.12.0", ] [project.optional-dependencies] diff --git a/src/heta/kb/discovery.py b/src/heta/kb/discovery.py index 6969b25..1670284 100644 --- a/src/heta/kb/discovery.py +++ b/src/heta/kb/discovery.py @@ -6,6 +6,7 @@ from heta.config.schema import HetaConfig from heta.kb.code_parser import CODE_EXTENSIONS +from heta.kb.html_parser import HTML_EXTENSIONS PLAIN_EXTENSIONS = {".md", ".markdown", ".txt"} MINERU_EXTENSIONS = {".pdf"} @@ -14,7 +15,7 @@ def supported_extensions(config: HetaConfig) -> set[str]: - extensions = set(PLAIN_EXTENSIONS) | IMAGE_EXTENSIONS | AUDIO_EXTENSIONS | CODE_EXTENSIONS + extensions = set(PLAIN_EXTENSIONS) | IMAGE_EXTENSIONS | AUDIO_EXTENSIONS | CODE_EXTENSIONS | HTML_EXTENSIONS if config.mineru.enable: extensions |= MINERU_EXTENSIONS return extensions diff --git a/src/heta/kb/html_parser.py b/src/heta/kb/html_parser.py new file mode 100644 index 0000000..4b10cc8 --- /dev/null +++ b/src/heta/kb/html_parser.py @@ -0,0 +1,325 @@ +"""Structure-preserving HTML parsing for Little Heta KB inserts.""" + +from __future__ import annotations + +import json +import re +import shutil +from dataclasses import asdict, dataclass +from datetime import date +from pathlib import Path +from urllib.parse import urljoin, urlparse + +from bs4 import BeautifulSoup +from bs4.element import NavigableString, Tag + +HTML_EXTENSIONS = {".html", ".htm"} +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"} +NOISE_TAGS = {"script", "style", "nav", "footer", "aside", "iframe", "button", "noscript"} + + +@dataclass(frozen=True) +class HtmlAsset: + id: str + raw_path: str | None + original_src: str + alt: str + title: str + section: str + near_text_before: str + near_text_after: str + + +def parse_html_markdown(source_path: Path, archived_path: Path) -> str: + html = source_path.read_text(encoding="utf-8", errors="replace") + soup = BeautifulSoup(html, "html.parser") + _remove_noise(soup) + + title = _page_title(soup, source_path) + description = _description(soup) + body = soup.find("body") or soup + asset_dir = archived_path.parent / "assets" / archived_path.stem + converter = _HtmlMarkdownConverter(source_path=source_path, asset_dir=asset_dir, asset_stem=archived_path.stem) + content = converter.convert(body).strip() + summary = _summary(title, description, content) + + if converter.assets: + asset_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "source_html": archived_path.name, + "assets": [asdict(asset) for asset in converter.assets], + } + (asset_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + + return build_html_markdown( + title=f"Web Page - {title}", + source_name=archived_path.name, + summary=summary, + content=content, + ) + + +def build_html_markdown(*, title: str, source_name: str, summary: str, content: str) -> str: + return ( + "---\n" + f"title: {title}\n" + f"sources: [{source_name}]\n" + f"updated: {date.today().isoformat()}\n" + "---\n\n" + "## Summary\n" + f"{summary.strip()}\n\n" + "## Content\n\n" + f"{content.strip() or 'No main HTML content extracted.'}\n\n" + "## Source\n" + f"- {source_name}\n" + ) + + +class _HtmlMarkdownConverter: + def __init__(self, *, source_path: Path, asset_dir: Path, asset_stem: str) -> None: + self.source_path = source_path + self.asset_dir = asset_dir + self.asset_stem = asset_stem + self.assets: list[HtmlAsset] = [] + self.section_stack: list[str] = [] + self.recent_text: str = "" + + def convert(self, node: Tag) -> str: + parts = [self._convert_child(child) for child in node.children] + return _compact_blocks("\n".join(part for part in parts if part.strip())) + + def _convert_child(self, node) -> str: + if isinstance(node, NavigableString): + text = _clean_text(str(node)) + self._remember_text(text) + return text + if not isinstance(node, Tag): + return "" + + name = node.name.lower() if node.name else "" + if name in NOISE_TAGS: + return "" + if name in {"h1", "h2", "h3", "h4", "h5", "h6"}: + return self._heading(node, int(name[1])) + if name == "p": + return self._paragraph(node) + if name in {"ul", "ol"}: + return self._list(node, ordered=name == "ol") + if name == "li": + return self._inline_children(node) + if name == "blockquote": + text = self.convert(node) + return "\n".join(f"> {line}" if line else ">" for line in text.splitlines()) + if name == "pre": + return self._pre(node) + if name == "code": + return f"`{_clean_text(node.get_text(' ', strip=True))}`" + if name == "table": + return self._table(node) + if name == "img": + return self._image(node) + if name == "br": + return "\n" + if name in {"strong", "b"}: + return f"**{self._inline_children(node)}**" + if name in {"em", "i"}: + return f"*{self._inline_children(node)}*" + if name == "a": + return self._link(node) + + return self.convert(node) + + def _heading(self, node: Tag, html_level: int) -> str: + text = _clean_text(node.get_text(" ", strip=True)) + if not text: + return "" + markdown_level = min(6, html_level + 2) + depth = markdown_level - 2 + self.section_stack = self.section_stack[: max(0, depth - 1)] + [text] + self._remember_text(text) + return f"{'#' * markdown_level} {text}" + + def _paragraph(self, node: Tag) -> str: + text = self._inline_children(node) + self._remember_text(text) + return text + + def _list(self, node: Tag, *, ordered: bool) -> str: + lines: list[str] = [] + index = 1 + for child in node.find_all("li", recursive=False): + text = _compact_inline(self._inline_children(child)) + if not text: + continue + marker = f"{index}." if ordered else "-" + lines.append(f"{marker} {text}") + index += 1 + return "\n".join(lines) + + def _pre(self, node: Tag) -> str: + code = node.get_text("\n", strip=False).strip("\n") + language = "" + code_tag = node.find("code") + if code_tag: + classes = " ".join(code_tag.get("class", [])) + match = re.search(r"language-([\w+-]+)", classes) + if match: + language = match.group(1) + return f"```{language}\n{code}\n```" + + def _table(self, node: Tag) -> str: + rows: list[list[str]] = [] + for tr in node.find_all("tr"): + cells = tr.find_all(["th", "td"], recursive=False) + if cells: + rows.append([_compact_inline(cell.get_text(" ", strip=True)) for cell in cells]) + if not rows: + return "" + width = max(len(row) for row in rows) + normalized = [row + [""] * (width - len(row)) for row in rows] + header = normalized[0] + separator = ["---"] * width + body = normalized[1:] + table_lines = [_markdown_row(header), _markdown_row(separator), *[_markdown_row(row) for row in body]] + text = "\n".join(table_lines) + self._remember_text(" ".join(" ".join(row) for row in normalized)) + return text + + def _image(self, node: Tag) -> str: + src = _img_src(node) + if not src: + return "" + alt = _clean_text(str(node.get("alt") or "")) + title = _clean_text(str(node.get("title") or "")) + markdown_src, raw_path = self._image_path(src) + label = alt or title or Path(urlparse(src).path).name or "HTML image" + section = self.section_stack[-1] if self.section_stack else "" + asset = HtmlAsset( + id=f"img-{len(self.assets) + 1:03d}", + raw_path=raw_path, + original_src=src, + alt=alt, + title=title, + section=section, + near_text_before=self.recent_text, + near_text_after="", + ) + self.assets.append(asset) + note = alt or title + if note: + return f"![{_escape_brackets(label)}](<{markdown_src}>)\n\nImage note: {note}." + return f"![{_escape_brackets(label)}](<{markdown_src}>)" + + def _image_path(self, src: str) -> tuple[str, str | None]: + parsed = urlparse(src) + if parsed.scheme in {"http", "https", "data"} or src.startswith("//"): + return src, None + local = (self.source_path.parent / src).resolve() + if not local.exists() or local.suffix.lower() not in IMAGE_EXTENSIONS: + return src, None + self.asset_dir.mkdir(parents=True, exist_ok=True) + target = self.asset_dir / f"img-{len(self.assets) + 1:03d}{local.suffix.lower()}" + shutil.copy2(local, target) + raw_path = f"raw/assets/{self.asset_stem}/{target.name}" + markdown_path = f"../../raw/assets/{self.asset_stem}/{target.name}" + return markdown_path, raw_path + + def _link(self, node: Tag) -> str: + text = self._inline_children(node) or _clean_text(node.get_text(" ", strip=True)) + href = str(node.get("href") or "").strip() + if not href: + return text + return f"[{_escape_brackets(text)}](<{href}>)" + + def _inline_children(self, node: Tag) -> str: + parts = [self._convert_child(child) for child in node.children] + return _compact_inline(" ".join(part for part in parts if part.strip())) + + def _remember_text(self, text: str) -> None: + cleaned = _compact_inline(text) + if cleaned: + self.recent_text = cleaned[-240:] + + +def _remove_noise(soup: BeautifulSoup) -> None: + for tag in soup.find_all(list(NOISE_TAGS)): + tag.decompose() + + +def _page_title(soup: BeautifulSoup, source_path: Path) -> str: + for selector in ("h1", "title"): + tag = soup.find(selector) + if tag: + text = _clean_text(tag.get_text(" ", strip=True)) + if text: + return text + return source_path.stem.replace("_", " ").replace("-", " ").title() + + +def _description(soup: BeautifulSoup) -> str: + for attrs in ({"name": "description"}, {"property": "og:description"}): + tag = soup.find("meta", attrs=attrs) + if tag and tag.get("content"): + return _clean_text(str(tag.get("content"))) + return "" + + +def _summary(title: str, description: str, content: str) -> str: + if description: + return description + text = _strip_markdown(content) + if text: + return text[:240].rstrip() + ("..." if len(text) > 240 else "") + return f"HTML page about {title}." + + +def _img_src(node: Tag) -> str: + for key in ("data-src", "data-original", "data-lazy-src", "src"): + value = node.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + srcset = node.get("srcset") + if isinstance(srcset, str) and srcset.strip(): + return srcset.split(",")[-1].strip().split()[0] + return "" + + +def _markdown_row(row: list[str]) -> str: + return "| " + " | ".join(cell.replace("|", "\\|") for cell in row) + " |" + + +def _clean_text(text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + +def _compact_inline(text: str) -> str: + return re.sub(r"[ \t]+", " ", text).strip() + + +def _compact_blocks(text: str) -> str: + lines = [line.rstrip() for line in text.splitlines()] + compact: list[str] = [] + blank = False + for line in lines: + if not line.strip(): + if not blank: + compact.append("") + blank = True + continue + compact.append(line) + blank = False + return "\n".join(compact).strip() + + +def _strip_markdown(text: str) -> str: + cleaned = re.sub(r"!\[[^\]]*]\([^)]+\)", " ", text) + cleaned = re.sub(r"\[([^\]]+)]\([^)]+\)", r"\1", cleaned) + cleaned = re.sub(r"[#*_`>|-]+", " ", cleaned) + return _clean_text(cleaned) + + +def _escape_brackets(text: str) -> str: + return text.replace("[", "\\[").replace("]", "\\]") + + +__all__ = ["HTML_EXTENSIONS", "HtmlAsset", "build_html_markdown", "parse_html_markdown"] diff --git a/src/heta/kb/parser.py b/src/heta/kb/parser.py index 8cf8931..d4757ea 100644 --- a/src/heta/kb/parser.py +++ b/src/heta/kb/parser.py @@ -10,6 +10,7 @@ from heta.config.schema import HetaConfig from heta.kb.audio_parser import AUDIO_EXTENSIONS, parse_audio_markdown from heta.kb.code_parser import CODE_EXTENSIONS, parse_code_markdown +from heta.kb.html_parser import HTML_EXTENSIONS, parse_html_markdown from heta.kb.image_parser import IMAGE_EXTENSIONS, parse_image_markdown from heta.kb.models import ParsedDocument from heta.kb.text import extract_title @@ -25,6 +26,8 @@ def parse_document(source_path: Path, archived_path: Path, config: HetaConfig) - markdown = parse_image_markdown(source_path, archived_path, config) elif suffix in AUDIO_EXTENSIONS: markdown = parse_audio_markdown(source_path, archived_path, config) + elif suffix in HTML_EXTENSIONS: + markdown = parse_html_markdown(source_path, archived_path) elif suffix in CODE_EXTENSIONS: markdown = parse_code_markdown(source_path, archived_path) else: diff --git a/tests/test_html_parser.py b/tests/test_html_parser.py new file mode 100644 index 0000000..43f9507 --- /dev/null +++ b/tests/test_html_parser.py @@ -0,0 +1,97 @@ +from pathlib import Path + +from heta.config.schema import HetaConfig, InsertPlanningConfig, LLMConfig, MinerUConfig, VectorIndexConfig +from heta.kb.html_parser import parse_html_markdown +from heta.kb.parser import parse_document +from heta.kb.text import extract_title + + +def _config() -> HetaConfig: + return HetaConfig( + version=1, + llm=LLMConfig(provider="qwen", api_key="sk-test"), + mineru=MinerUConfig.disabled(), + vector_index=VectorIndexConfig(enable=False), + insert_planning=InsertPlanningConfig.enabled(), + ) + + +def test_parse_html_markdown_preserves_structure_and_inline_images(tmp_path: Path) -> None: + image = tmp_path / "arch.png" + image.write_bytes(b"png") + source = tmp_path / "attention.html" + archived = tmp_path / "raw" / "2026-05-15_attention.html" + archived.parent.mkdir() + source.write_text( + """ + + + + Attention Mechanism + + + + + +

Attention Mechanism

+

Source: Bahdanau and Vaswani.

+

Overview

+

The model focuses on relevant input tokens.

+

Types

+
TypeDescription
Self-AttentionTokens attend to tokens.
+

Architecture

+

The Transformer uses multi-head attention.

+ Transformer architecture + + +""", + encoding="utf-8", + ) + archived.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + + markdown = parse_html_markdown(source, archived) + + assert extract_title(markdown, "fallback") == "Web Page - Attention Mechanism" + assert "### Metadata" not in markdown + assert "Raw HTML" not in markdown + assert "Navigation noise" not in markdown + assert "### Attention Mechanism" in markdown + assert "#### Overview" in markdown + assert "| Type | Description |" in markdown + assert "![Transformer architecture](<../../raw/assets/2026-05-15_attention/img-001.png>)" in markdown + assert "Image note: Transformer architecture." in markdown + assert (tmp_path / "raw" / "assets" / "2026-05-15_attention" / "img-001.png").exists() + manifest = (tmp_path / "raw" / "assets" / "2026-05-15_attention" / "manifest.json").read_text(encoding="utf-8") + assert '"original_src": "arch.png"' in manifest + assert '"section": "Architecture"' in manifest + + +def test_parse_html_markdown_keeps_remote_images_as_urls(tmp_path: Path) -> None: + source = tmp_path / "remote.htm" + archived = tmp_path / "raw" / "2026-05-15_remote.htm" + archived.parent.mkdir() + source.write_text( + '

Remote Page

Intro.

Remote plot', + encoding="utf-8", + ) + archived.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + + markdown = parse_html_markdown(source, archived) + + assert "![Remote plot]()" in markdown + manifest = (tmp_path / "raw" / "assets" / "2026-05-15_remote" / "manifest.json").read_text(encoding="utf-8") + assert '"original_src": "https://example.com/plot.png"' in manifest + assert '"raw_path": null' in manifest + + +def test_parse_document_accepts_html_branch(tmp_path: Path) -> None: + source = tmp_path / "page.html" + archived = tmp_path / "2026-05-15_page.html" + source.write_text("

HTML Page

Hello.

", encoding="utf-8") + archived.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + + document = parse_document(source, archived, _config()) + + assert document.title == "Web Page - HTML Page" + assert document.metadata["extension"] == ".html" + assert "### HTML Page" in document.markdown_content diff --git a/tests/test_kb_insert.py b/tests/test_kb_insert.py index d48ffdf..ed02843 100644 --- a/tests/test_kb_insert.py +++ b/tests/test_kb_insert.py @@ -180,7 +180,7 @@ def test_collect_insert_files_accepts_audio_and_video(tmp_path: Path) -> None: assert files == [audio, video] -def test_collect_insert_files_accepts_code_but_not_html(tmp_path: Path) -> None: +def test_collect_insert_files_accepts_code_and_html(tmp_path: Path) -> None: code = tmp_path / "module.py" html = tmp_path / "index.html" code.write_text("def run():\n pass\n", encoding="utf-8") @@ -188,7 +188,7 @@ def test_collect_insert_files_accepts_code_but_not_html(tmp_path: Path) -> None: files = collect_insert_files([tmp_path], _config()) - assert files == [code] + assert files == [html, code] def test_collect_directory_skips_workspace(tmp_path: Path) -> None: diff --git a/uv.lock b/uv.lock index c85d84e..d722891 100644 --- a/uv.lock +++ b/uv.lock @@ -34,6 +34,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -353,6 +366,7 @@ name = "little-heta" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "beautifulsoup4" }, { name = "openai" }, { name = "pypdf" }, { name = "pyyaml" }, @@ -374,6 +388,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "beautifulsoup4", specifier = ">=4.12.0" }, { name = "openai", specifier = ">=1.0.0" }, { name = "pypdf", specifier = ">=4.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, @@ -726,6 +741,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "sqlite-vec" version = "0.1.9" From e3ab2789cc458c65fb12ea45c59445a7295c2b55 Mon Sep 17 00:00:00 2001 From: 77one Date: Fri, 15 May 2026 11:24:21 +0800 Subject: [PATCH 24/44] fix: clean html wiki page extraction --- src/heta/kb/html_parser.py | 82 +++++++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/src/heta/kb/html_parser.py b/src/heta/kb/html_parser.py index 4b10cc8..f41e66d 100644 --- a/src/heta/kb/html_parser.py +++ b/src/heta/kb/html_parser.py @@ -16,6 +16,28 @@ HTML_EXTENSIONS = {".html", ".htm"} IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"} NOISE_TAGS = {"script", "style", "nav", "footer", "aside", "iframe", "button", "noscript"} +NOISE_SELECTORS = ( + "[role='navigation']", + "[role='banner']", + "[role='contentinfo']", + "[role='search']", + ".mw-editsection", + ".mw-jump-link", + ".mw-portlet", + ".mw-sidebar", + ".noprint", + ".navbox", + ".navbar", + ".printfooter", + ".shortdescription", + ".sidebar", + ".toc", + "#catlinks", + "#footer", + "#mw-navigation", + "#p-lang-btn", + "#siteNotice", +) @dataclass(frozen=True) @@ -37,7 +59,7 @@ def parse_html_markdown(source_path: Path, archived_path: Path) -> str: title = _page_title(soup, source_path) description = _description(soup) - body = soup.find("body") or soup + body = _main_content(soup) asset_dir = archived_path.parent / "assets" / archived_path.stem converter = _HtmlMarkdownConverter(source_path=source_path, asset_dir=asset_dir, asset_stem=archived_path.stem) content = converter.convert(body).strip() @@ -225,6 +247,8 @@ def _image_path(self, src: str) -> tuple[str, str | None]: return markdown_path, raw_path def _link(self, node: Tag) -> str: + if node.find("img"): + return self._inline_children(node) text = self._inline_children(node) or _clean_text(node.get_text(" ", strip=True)) href = str(node.get("href") or "").strip() if not href: @@ -244,6 +268,27 @@ def _remember_text(self, text: str) -> None: def _remove_noise(soup: BeautifulSoup) -> None: for tag in soup.find_all(list(NOISE_TAGS)): tag.decompose() + for selector in NOISE_SELECTORS: + for tag in soup.select(selector): + tag.decompose() + + +def _main_content(soup: BeautifulSoup) -> Tag: + selectors = ( + ".mw-parser-output", + "article", + "main", + "[role='main']", + "#bodyContent", + "#mw-content-text", + "#content", + "body", + ) + for selector in selectors: + tag = soup.select_one(selector) + if tag and _clean_text(tag.get_text(" ", strip=True)): + return tag + return soup def _page_title(soup: BeautifulSoup, source_path: Path) -> str: @@ -267,12 +312,37 @@ def _description(soup: BeautifulSoup) -> str: def _summary(title: str, description: str, content: str) -> str: if description: return description - text = _strip_markdown(content) - if text: - return text[:240].rstrip() + ("..." if len(text) > 240 else "") + for block in re.split(r"\n\s*\n", content): + text = _lead_summary_text(_strip_markdown(block)) + if _summary_candidate(text): + return text[:240].rstrip() + ("..." if len(text) > 240 else "") return f"HTML page about {title}." +def _lead_summary_text(text: str) -> str: + text = re.sub(r"^For other uses, see .*?\.\s*", "", text) + match = re.search(r"\bIn\s+[A-Za-z]", text) + if match and 0 < match.start() < 420: + return text[match.start() :] + return text + + +def _summary_candidate(text: str) -> bool: + if len(text) < 80: + return False + lowered = text.lower() + skipped_prefixes = ( + "for other uses", + "image note:", + "jump to content", + "main article:", + "see also:", + ) + if lowered.startswith(skipped_prefixes): + return False + return any(char.isalpha() for char in text) + + def _img_src(node: Tag) -> str: for key in ("data-src", "data-original", "data-lazy-src", "src"): value = node.get(key) @@ -312,7 +382,9 @@ def _compact_blocks(text: str) -> str: def _strip_markdown(text: str) -> str: - cleaned = re.sub(r"!\[[^\]]*]\([^)]+\)", " ", text) + cleaned = re.sub(r"!\[[^\]]*]\(<[^>]*>\)", " ", text) + cleaned = re.sub(r"!\[[^\]]*]\([^)]+\)", " ", cleaned) + cleaned = re.sub(r"\[([^\]]+)]\(<[^>]*>\)", r"\1", cleaned) cleaned = re.sub(r"\[([^\]]+)]\([^)]+\)", r"\1", cleaned) cleaned = re.sub(r"[#*_`>|-]+", " ", cleaned) return _clean_text(cleaned) From 2e4d463a2b1f9ac86b7c3a7f091fff7825738c67 Mon Sep 17 00:00:00 2001 From: 77one Date: Fri, 15 May 2026 11:29:38 +0800 Subject: [PATCH 25/44] fix: improve html article extraction --- src/heta/kb/html_parser.py | 49 +++++++++++++++++++++++++++++++++++++- tests/test_html_parser.py | 38 +++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/heta/kb/html_parser.py b/src/heta/kb/html_parser.py index f41e66d..1598815 100644 --- a/src/heta/kb/html_parser.py +++ b/src/heta/kb/html_parser.py @@ -22,9 +22,12 @@ "[role='contentinfo']", "[role='search']", ".mw-editsection", + ".mw-indicators", ".mw-jump-link", ".mw-portlet", ".mw-sidebar", + ".ambox", + ".metadata", ".noprint", ".navbox", ".navbar", @@ -32,6 +35,7 @@ ".shortdescription", ".sidebar", ".toc", + ".topicon", "#catlinks", "#footer", "#mw-navigation", @@ -63,7 +67,8 @@ def parse_html_markdown(source_path: Path, archived_path: Path) -> str: asset_dir = archived_path.parent / "assets" / archived_path.stem converter = _HtmlMarkdownConverter(source_path=source_path, asset_dir=asset_dir, asset_stem=archived_path.stem) content = converter.convert(body).strip() - summary = _summary(title, description, content) + content = _ensure_content_title(content, title) + summary = _html_summary(body, title, description) or _summary(title, description, content) if converter.assets: asset_dir.mkdir(parents=True, exist_ok=True) @@ -291,6 +296,13 @@ def _main_content(soup: BeautifulSoup) -> Tag: return soup +def _ensure_content_title(content: str, title: str) -> str: + lines = [line.strip() for line in content.splitlines() if line.strip()] + if lines and lines[0] == f"### {title}": + return content + return f"### {title}\n{content}" if content else f"### {title}" + + def _page_title(soup: BeautifulSoup, source_path: Path) -> str: for selector in ("h1", "title"): tag = soup.find(selector) @@ -309,6 +321,41 @@ def _description(soup: BeautifulSoup) -> str: return "" +def _html_summary(body: Tag, title: str, description: str) -> str: + if description: + return description + for paragraph in body.find_all("p"): + if _is_non_content_node(paragraph): + continue + text = _lead_summary_text(_clean_text(paragraph.get_text(" ", strip=True))) + if _summary_candidate(text): + return text[:240].rstrip() + ("..." if len(text) > 240 else "") + return "" + + +def _is_non_content_node(tag: Tag) -> bool: + blocked_tags = {"table", "figure", "aside", "nav", "footer", "header"} + blocked_classes = { + "ambox", + "hatnote", + "infobox", + "metadata", + "navbox", + "noprint", + "shortdescription", + "sidebar", + } + for parent in [tag, *tag.parents]: + if not isinstance(parent, Tag): + continue + if parent.name and parent.name.lower() in blocked_tags: + return True + classes = set(parent.get("class", [])) + if classes & blocked_classes: + return True + return False + + def _summary(title: str, description: str, content: str) -> str: if description: return description diff --git a/tests/test_html_parser.py b/tests/test_html_parser.py index 43f9507..43252e0 100644 --- a/tests/test_html_parser.py +++ b/tests/test_html_parser.py @@ -84,6 +84,44 @@ def test_parse_html_markdown_keeps_remote_images_as_urls(tmp_path: Path) -> None assert '"raw_path": null' in manifest +def test_parse_html_markdown_prefers_main_content_and_clean_summary(tmp_path: Path) -> None: + source = tmp_path / "wiki.html" + archived = tmp_path / "raw" / "2026-05-15_wiki.html" + archived.parent.mkdir() + source.write_text( + """ + +Knowledge graph + + +
+
Page semi-protected
+
This article has multiple issues.
+
+

+

For other uses, see Knowledge graph (disambiguation).

+

Knowledge graph is a graph-structured knowledge base used to represent entities, facts, and relationships for retrieval and reasoning systems.

+

History

+

The term has been used in several database and semantic web contexts.

+
+
+ + +""", + encoding="utf-8", + ) + archived.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + + markdown = parse_html_markdown(source, archived) + + assert "Jump to content" not in markdown + assert "Page semi-protected" not in markdown + assert "This article has multiple issues" not in markdown + assert "### Knowledge graph" in markdown + assert "## Summary\nKnowledge graph is a graph-structured knowledge base" in markdown + assert "#### History" in markdown + + def test_parse_document_accepts_html_branch(tmp_path: Path) -> None: source = tmp_path / "page.html" archived = tmp_path / "2026-05-15_page.html" From 0cff8da2800fe6df13e3283a51cf7097c56fa7a9 Mon Sep 17 00:00:00 2001 From: 77one Date: Fri, 15 May 2026 11:35:36 +0800 Subject: [PATCH 26/44] fix: improve html documentation extraction --- src/heta/kb/html_parser.py | 36 +++++++++++++++++++++++++++++++----- tests/test_html_parser.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/heta/kb/html_parser.py b/src/heta/kb/html_parser.py index 1598815..3ba74a9 100644 --- a/src/heta/kb/html_parser.py +++ b/src/heta/kb/html_parser.py @@ -7,15 +7,16 @@ import shutil from dataclasses import asdict, dataclass from datetime import date +from html import unescape from pathlib import Path from urllib.parse import urljoin, urlparse from bs4 import BeautifulSoup -from bs4.element import NavigableString, Tag +from bs4.element import Comment, NavigableString, Tag HTML_EXTENSIONS = {".html", ".htm"} IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"} -NOISE_TAGS = {"script", "style", "nav", "footer", "aside", "iframe", "button", "noscript"} +NOISE_TAGS = {"script", "style", "nav", "footer", "header", "aside", "iframe", "button", "noscript"} NOISE_SELECTORS = ( "[role='navigation']", "[role='banner']", @@ -27,7 +28,13 @@ ".mw-portlet", ".mw-sidebar", ".ambox", + ".docNav", + ".docSearch", + ".navfooter", + ".navheader", ".metadata", + ".menu", + ".nosearch", ".noprint", ".navbox", ".navbar", @@ -37,6 +44,7 @@ ".toc", ".topicon", "#catlinks", + "#docSearchForm", "#footer", "#mw-navigation", "#p-lang-btn", @@ -116,6 +124,8 @@ def convert(self, node: Tag) -> str: return _compact_blocks("\n".join(part for part in parts if part.strip())) def _convert_child(self, node) -> str: + if isinstance(node, Comment): + return "" if isinstance(node, NavigableString): text = _clean_text(str(node)) self._remember_text(text) @@ -281,6 +291,12 @@ def _remove_noise(soup: BeautifulSoup) -> None: def _main_content(soup: BeautifulSoup) -> Tag: selectors = ( ".mw-parser-output", + "#docContent", + "#main-content", + "#maincontent", + ".document", + ".documentwrapper", + ".body", "article", "main", "[role='main']", @@ -322,7 +338,7 @@ def _description(soup: BeautifulSoup) -> str: def _html_summary(body: Tag, title: str, description: str) -> str: - if description: + if _description_candidate(description): return description for paragraph in body.find_all("p"): if _is_non_content_node(paragraph): @@ -330,7 +346,17 @@ def _html_summary(body: Tag, title: str, description: str) -> str: text = _lead_summary_text(_clean_text(paragraph.get_text(" ", strip=True))) if _summary_candidate(text): return text[:240].rstrip() + ("..." if len(text) > 240 else "") - return "" + return description + + +def _description_candidate(text: str) -> bool: + if not _summary_candidate(text): + return False + lowered = text.lower() + if "…" in text or lowered.endswith("..."): + return False + bad_fragments = ("table of contents", "part i.", "newpp limit report") + return not any(fragment in lowered for fragment in bad_fragments) def _is_non_content_node(tag: Tag) -> bool: @@ -406,7 +432,7 @@ def _markdown_row(row: list[str]) -> str: def _clean_text(text: str) -> str: - return re.sub(r"\s+", " ", text).strip() + return re.sub(r"\s+", " ", unescape(text)).strip() def _compact_inline(text: str) -> str: diff --git a/tests/test_html_parser.py b/tests/test_html_parser.py index 43252e0..dfeaf87 100644 --- a/tests/test_html_parser.py +++ b/tests/test_html_parser.py @@ -122,6 +122,44 @@ def test_parse_html_markdown_prefers_main_content_and_clean_summary(tmp_path: Pa assert "#### History" in markdown +def test_parse_html_markdown_cleans_document_site_shell(tmp_path: Path) -> None: + source = tmp_path / "docs.html" + archived = tmp_path / "raw" / "2026-05-15_docs.html" + archived.parent.mkdir() + source.write_text( + """ + + + SQL Reference + + + +
LogoSmall. Fast. Reliable.
+
+
+ +

SQL Reference

+

SQL Reference explains statements, expressions, and data manipulation concepts for database users.

+

Statements

+

The reference lists supported statement syntax.

+
+ + +""", + encoding="utf-8", + ) + archived.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + + markdown = parse_html_markdown(source, archived) + + assert "Small. Fast. Reliable" not in markdown + assert "IE hack" not in markdown + assert "Previous topic" not in markdown + assert "### SQL Reference" in markdown + assert "#### Statements" in markdown + assert "## Summary\nSQL Reference explains statements" in markdown + + def test_parse_document_accepts_html_branch(tmp_path: Path) -> None: source = tmp_path / "page.html" archived = tmp_path / "2026-05-15_page.html" From 56e3b11e4a27b108c3e94ab47bd3601b870f877c Mon Sep 17 00:00:00 2001 From: wangwenwu Date: Fri, 15 May 2026 11:39:18 +0800 Subject: [PATCH 27/44] feat: merge feature/insight-distill-then-answer into main --- src/heta/cli/ask.py | 5 ++ src/heta/cli/mem_show.py | 80 ++++++++++++------- src/heta/mem/db.py | 20 +++++ src/heta/mem/kb_invalidate.py | 10 ++- src/heta/mem/kb_store.py | 28 +++++-- src/heta/mem/kb_writer.py | 145 +++++++++++++--------------------- src/heta/mem/models.py | 13 ++- src/heta/mem/prompts.py | 55 ++++++------- src/heta/query/agent.py | 105 +++++++++++++++++++++--- src/heta/query/models.py | 8 ++ src/heta/query/smart_query.py | 3 +- tests/test_kb_invalidate.py | 22 +++++- 12 files changed, 318 insertions(+), 176 deletions(-) diff --git a/src/heta/cli/ask.py b/src/heta/cli/ask.py index 543eed1..631dc65 100644 --- a/src/heta/cli/ask.py +++ b/src/heta/cli/ask.py @@ -59,6 +59,11 @@ def ask_command( console.print("\n[bold]kb result:[/bold]") paths = [s.path for s in result.kb_result.sources] console.print(f" used sources: {paths or '(empty)'}") + if result.kb_result.insights: + console.print(f" agent insights ({len(result.kb_result.insights)}):") + for i, qi in enumerate(result.kb_result.insights, 1): + console.print(f" [dim][{i}] sources:[/dim] {qi.source_paths}") + console.print(f" {qi.text}") console.print(f" written_back: {result.written_back}") console.print("[bold yellow]──────────[/bold yellow]\n") diff --git a/src/heta/cli/mem_show.py b/src/heta/cli/mem_show.py index 6d89ca9..8ae7862 100644 --- a/src/heta/cli/mem_show.py +++ b/src/heta/cli/mem_show.py @@ -43,6 +43,7 @@ def insights_command( init_db(conn) try: rows = _fetch_insights(conn, source=source, question=question, limit=limit) + total = _count_total(conn, source=source, question=question) finally: conn.close() @@ -50,7 +51,6 @@ def insights_command( console.print(f"[{MUTED}]No insights matched.[/]") return - total = _count_total(source=source, question=question) table = Table( title=f"kb_insights ({len(rows)} of {total} shown)", show_lines=not full, @@ -58,7 +58,7 @@ def insights_command( ) table.add_column("#", style="dim", justify="right", no_wrap=True) table.add_column("created", style=MUTED, no_wrap=True) - table.add_column("source", style=MUTED) + table.add_column("sources", style=MUTED) table.add_column("question", style=MUTED) table.add_column("insight") @@ -67,10 +67,13 @@ def insights_command( question_text = row["question"] or "" if not full: question_text = _truncate(question_text, 50) + sources_text = "\n".join(row["source_paths"]) if full else _truncate( + ", ".join(row["source_paths"]), 40 + ) table.add_row( str(i), _format_ts(row["created_at"]), - row["source_path"] or "", + sources_text, question_text, insight_text, ) @@ -83,39 +86,60 @@ def _fetch_insights( source: str | None, question: str | None, limit: int, -) -> list[sqlite3.Row]: - where, params = _build_where(source=source, question=question) - sql = f""" - SELECT i.insight, i.question, i.source_path, i.created_at +) -> list[dict]: + """Fetch insights and their full source_paths list.""" + base_sql = """ + SELECT i.memory_id, i.insight, i.question, i.created_at FROM kb_insight i JOIN memory_meta m ON m.memory_id = i.memory_id - WHERE m.status = 'active' {where} - ORDER BY i.created_at DESC - LIMIT ? + WHERE m.status = 'active' """ - return conn.execute(sql, (*params, max(1, limit))).fetchall() - - -def _count_total(*, source: str | None, question: str | None) -> int: - conn = get_connection(db_path(), with_vec=True) - init_db(conn) - try: - where, params = _build_where(source=source, question=question) - row = conn.execute( - f"SELECT COUNT(*) FROM kb_insight i JOIN memory_meta m ON m.memory_id = i.memory_id " - f"WHERE m.status = 'active' {where}", - params, - ).fetchone() - return int(row[0]) - finally: - conn.close() + clauses, params = _build_filters(source=source, question=question) + sql = f"{base_sql} {clauses} ORDER BY i.created_at DESC LIMIT ?" + params.append(max(1, limit)) + rows = conn.execute(sql, params).fetchall() + + results = [] + for r in rows: + paths = [ + row[0] + for row in conn.execute( + "SELECT source_path FROM kb_insight_source WHERE memory_id = ? ORDER BY source_path", + (r["memory_id"],), + ).fetchall() + ] + results.append({ + "insight": r["insight"], + "question": r["question"], + "source_paths": paths, + "created_at": r["created_at"], + }) + return results + + +def _count_total( + conn: sqlite3.Connection, + *, + source: str | None, + question: str | None, +) -> int: + base_sql = """ + SELECT COUNT(*) FROM kb_insight i + JOIN memory_meta m ON m.memory_id = i.memory_id + WHERE m.status = 'active' + """ + clauses, params = _build_filters(source=source, question=question) + row = conn.execute(f"{base_sql} {clauses}", params).fetchone() + return int(row[0]) -def _build_where(*, source: str | None, question: str | None) -> tuple[str, list]: +def _build_filters(*, source: str | None, question: str | None) -> tuple[str, list]: clauses: list[str] = [] params: list = [] if source: - clauses.append("AND i.source_path LIKE ?") + clauses.append( + "AND i.memory_id IN (SELECT memory_id FROM kb_insight_source WHERE source_path LIKE ?)" + ) params.append(f"%{source}%") if question: clauses.append("AND i.question LIKE ?") diff --git a/src/heta/mem/db.py b/src/heta/mem/db.py index edabdac..55d39c0 100644 --- a/src/heta/mem/db.py +++ b/src/heta/mem/db.py @@ -99,6 +99,15 @@ def init_db(conn: sqlite3.Connection) -> None: CREATE INDEX IF NOT EXISTS idx_kb_insight_source ON kb_insight(source_path); CREATE INDEX IF NOT EXISTS idx_kb_insight_wiki ON kb_insight(wiki_id); + + CREATE TABLE IF NOT EXISTS kb_insight_source ( + memory_id TEXT NOT NULL REFERENCES kb_insight(memory_id) ON DELETE CASCADE, + source_path TEXT NOT NULL, + PRIMARY KEY (memory_id, source_path) + ); + + CREATE INDEX IF NOT EXISTS idx_kb_insight_source_path + ON kb_insight_source(source_path); """) _migrate(conn) _ensure_vec_table(conn) @@ -125,6 +134,17 @@ def _migrate(conn: sqlite3.Connection) -> None: if "when_precision" not in l1_cols: conn.execute("ALTER TABLE l1_episodic ADD COLUMN when_precision TEXT") + # Backfill kb_insight_source from kb_insight.source_path for pre-existing rows. + # Idempotent: PRIMARY KEY (memory_id, source_path) prevents duplicates on rerun. + try: + conn.execute(""" + INSERT OR IGNORE INTO kb_insight_source (memory_id, source_path) + SELECT memory_id, source_path FROM kb_insight + WHERE source_path IS NOT NULL AND source_path != '' + """) + except Exception: + pass + # legacy tables from earlier design — kept so existing DBs don't break if "kb_source" not in tables: conn.execute("""CREATE TABLE kb_source ( diff --git a/src/heta/mem/kb_invalidate.py b/src/heta/mem/kb_invalidate.py index dbec6bd..29ac9b6 100644 --- a/src/heta/mem/kb_invalidate.py +++ b/src/heta/mem/kb_invalidate.py @@ -61,14 +61,17 @@ def invalidate_all() -> int: def delete_insights_by_paths(conn: sqlite3.Connection, paths: list[str]) -> int: - """Connection-level helper. Exposed for tests and callers with an open conn.""" + """Connection-level helper. Exposed for tests and callers with an open conn. + + An insight is invalidated if ANY of its source_paths matches a changed page. + """ if not paths: return 0 placeholders = ",".join("?" for _ in paths) ids = [ r[0] for r in conn.execute( - f"SELECT memory_id FROM kb_insight WHERE source_path IN ({placeholders})", + f"SELECT DISTINCT memory_id FROM kb_insight_source WHERE source_path IN ({placeholders})", paths, ).fetchall() ] @@ -77,7 +80,8 @@ def delete_insights_by_paths(conn: sqlite3.Connection, paths: list[str]) -> int: id_placeholders = ",".join("?" for _ in ids) # vec0 virtual table does not honour FK cascade; delete explicitly. conn.execute(f"DELETE FROM kb_insight_vec WHERE memory_id IN ({id_placeholders})", ids) - # memory_meta delete cascades to kb_insight via ON DELETE CASCADE. + # memory_meta delete cascades to kb_insight via ON DELETE CASCADE, + # which in turn cascades to kb_insight_source. conn.execute(f"DELETE FROM memory_meta WHERE memory_id IN ({id_placeholders})", ids) conn.commit() return len(ids) diff --git a/src/heta/mem/kb_store.py b/src/heta/mem/kb_store.py index 3fb06a3..7015cf7 100644 --- a/src/heta/mem/kb_store.py +++ b/src/heta/mem/kb_store.py @@ -10,6 +10,7 @@ def insert_kb_insight(conn: sqlite3.Connection, insight: KBInsight) -> None: + """Insert insight row plus one row per source_path into the join table.""" conn.execute( """INSERT INTO kb_insight (memory_id, insight, question, source_path, wiki_id, heading_path, created_at) @@ -17,6 +18,11 @@ def insert_kb_insight(conn: sqlite3.Connection, insight: KBInsight) -> None: (insight.memory_id, insight.insight, insight.question, insight.source_path, insight.wiki_id, insight.heading_path, insight.created_at), ) + for path in insight.source_paths: + conn.execute( + "INSERT OR IGNORE INTO kb_insight_source (memory_id, source_path) VALUES (?, ?)", + (insight.memory_id, path), + ) def insert_insight_embedding( @@ -28,6 +34,14 @@ def insert_insight_embedding( ) +def get_source_paths(conn: sqlite3.Connection, memory_id: str) -> list[str]: + rows = conn.execute( + "SELECT source_path FROM kb_insight_source WHERE memory_id = ? ORDER BY source_path", + (memory_id,), + ).fetchall() + return [r[0] for r in rows] + + def search_kb_insights( conn: sqlite3.Connection, embedding: list[float], @@ -43,12 +57,14 @@ def search_kb_insights( ORDER BY v.distance""", (sqlite_vec.serialize_float32(embedding), top_k), ).fetchall() - return [ - { - "memory_id": r["memory_id"], + results = [] + for r in rows: + mid = r["memory_id"] + results.append({ + "memory_id": mid, "insight": r["insight"], "source_path": r["source_path"], + "source_paths": get_source_paths(conn, mid), "score": 1.0 / (1.0 + float(r["distance"])), - } - for r in rows - ] + }) + return results diff --git a/src/heta/mem/kb_writer.py b/src/heta/mem/kb_writer.py index f13782a..f259783 100644 --- a/src/heta/mem/kb_writer.py +++ b/src/heta/mem/kb_writer.py @@ -1,4 +1,4 @@ -"""Extract and store KB insights into memory.""" +"""Store agent-distilled kb_insights into memory, with dedup.""" from __future__ import annotations @@ -16,25 +16,26 @@ from heta.mem.kb_store import insert_insight_embedding, insert_kb_insight, search_kb_insights from heta.mem.models import KBInsight, MemoryMeta from heta.mem.paths import db_path, ensure_mem_dir -from heta.mem.prompts import INSIGHT_DEDUP_PROMPT, KB_INSIGHT_EXTRACTION_PROMPT -from heta.query.tools import read_page +from heta.mem.prompts import INSIGHT_DEDUP_PROMPT +from heta.query.models import QueryInsight, QuerySource logger = logging.getLogger(__name__) -# Only invoke the LLM dedup check when semantic similarity is this high. -# Below the threshold the candidate is almost certainly a new insight. +# Below this similarity score the candidate is almost certainly a new insight, +# so we skip the LLM dedup call entirely. _DEDUP_SIMILARITY_THRESHOLD = 0.7 _DEDUP_TOP_K = 5 def remember_kb_insights( question: str, - sources, # list[QuerySource] — already validated to "used" by the KB agent + insights: list[QueryInsight], + sources: list[QuerySource], config: HetaConfig, base_dir: Path | None = None, ) -> int: - """Distil KB page content into insights and store them. Returns number of insights written.""" - if not sources: + """Persist agent-distilled insights into memory. Returns count stored after dedup.""" + if not insights: return 0 ensure_mem_dir() @@ -44,56 +45,52 @@ def remember_kb_insights( llm_client, llm_model = build_client(config) emb_client, emb_model = build_embedding_client(config) now = int(time.time()) - total = 0 - - for qs in sources: - path = qs.path - page_content = read_page(path, base_dir) - if page_content.startswith("error:"): - logger.warning("skip insight extraction for %s: %s", path, page_content) - continue - wiki_id = qs.wiki_id - heading_path = qs.heading_path + # Build a path → QuerySource map so insights can inherit wiki_id / heading + # from the primary source. + source_index = {s.path: s for s in sources} + total = 0 - insights = _extract_insights(llm_client, llm_model, question, page_content, config) - if not insights: - logger.info("no insights extracted from %s", path) + for qi in insights: + text = qi.text.strip() + if not text: continue - for insight_text in insights: - # Embed first so we can run the dedup check before writing. - emb = embed_text(emb_client, emb_model, insight_text) - - similar = search_kb_insights(conn, emb, top_k=_DEDUP_TOP_K) - if similar and similar[0]["score"] >= _DEDUP_SIMILARITY_THRESHOLD: - if _is_duplicate(llm_client, llm_model, insight_text, similar, config): - logger.debug("skip duplicate insight: %.80s", insight_text) - continue - - memory_id = str(uuid.uuid4()) - meta = MemoryMeta( - memory_id=memory_id, - memory_type="kb_insight", - session_id=None, - origin="kb_insight", - kb_uid=str(wiki_id) if wiki_id is not None else None, - created_at=now, - last_access_at=now, - ) - insight = KBInsight( - memory_id=memory_id, - insight=insight_text, - question=question, - source_path=path, - wiki_id=wiki_id, - heading_path=heading_path, - created_at=now, - ) - meta_store.insert_meta(conn, meta) - insert_kb_insight(conn, insight) - insert_insight_embedding(conn, memory_id, emb) - total += 1 + emb = embed_text(emb_client, emb_model, text) + similar = search_kb_insights(conn, emb, top_k=_DEDUP_TOP_K) + if similar and similar[0]["score"] >= _DEDUP_SIMILARITY_THRESHOLD: + if _is_duplicate(llm_client, llm_model, text, similar, config): + logger.debug("skip duplicate insight: %.80s", text) + continue + + primary_path = qi.source_paths[0] if qi.source_paths else "" + primary = source_index.get(primary_path) + wiki_id = primary.wiki_id if primary else None + heading_path = primary.heading_path if primary else None + + memory_id = str(uuid.uuid4()) + meta = MemoryMeta( + memory_id=memory_id, + memory_type="kb_insight", + session_id=None, + origin="kb_insight", + kb_uid=str(wiki_id) if wiki_id is not None else None, + created_at=now, + last_access_at=now, + ) + insight = KBInsight( + memory_id=memory_id, + insight=text, + source_paths=list(qi.source_paths), + created_at=now, + question=question, + wiki_id=wiki_id, + heading_path=heading_path, + ) + meta_store.insert_meta(conn, meta) + insert_kb_insight(conn, insight) + insert_insight_embedding(conn, memory_id, emb) + total += 1 conn.commit() conn.close() @@ -107,11 +104,11 @@ def _is_duplicate( similar: list[dict], config: HetaConfig, ) -> bool: - """Ask the LLM whether insight_text is already covered by any of the similar insights.""" + """Ask the LLM whether the new insight is fully covered by the similar set.""" existing_block = "\n".join( f"[{i + 1}] {s['insight']}" for i, s in enumerate(similar) ) - user_msg = f"New insight:\n{insight_text}\n\nExisting similar insights:\n{existing_block}" + user_msg = f"NEW insight:\n{insight_text}\n\nEXISTING similar insights:\n{existing_block}" kwargs: dict = { "model": model, "messages": [ @@ -132,42 +129,6 @@ def _is_duplicate( return False -def _extract_insights(client, model: str, question: str, page_content: str, config: HetaConfig) -> list[str]: - user_msg = f"Question:\n{question}\n\nKB page content:\n{page_content}" - kwargs: dict = { - "model": model, - "messages": [ - {"role": "system", "content": KB_INSIGHT_EXTRACTION_PROMPT}, - {"role": "user", "content": user_msg}, - ], - "temperature": 0.2, - } - body = extra_body(config) - if body: - kwargs["extra_body"] = body - try: - response = client.chat.completions.create(**kwargs) - raw = (response.choices[0].message.content or "").strip() - return _parse_insights(raw) - except Exception: - logger.exception("insight extraction failed for question: %.80s", question) - return [] - - -def _parse_insights(raw: str) -> list[str]: - text = raw.strip() - if text.startswith("```"): - lines = text.splitlines() - text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]) - try: - data = json.loads(text) - items = data.get("insights", []) - return [s for s in items if isinstance(s, str) and s.strip()] - except (json.JSONDecodeError, AttributeError): - logger.warning("failed to parse insights response: %s", raw[:200]) - return [] - - def _parse_json(raw: str) -> dict: text = raw.strip() if text.startswith("```"): diff --git a/src/heta/mem/models.py b/src/heta/mem/models.py index f2a9965..b2ab57f 100644 --- a/src/heta/mem/models.py +++ b/src/heta/mem/models.py @@ -74,9 +74,14 @@ class L2Semantic: @dataclass class KBInsight: memory_id: str - insight: str # distilled knowledge point - source_path: str # KB page this was extracted from + insight: str # distilled knowledge point + source_paths: list[str] # all KB pages this insight derives from created_at: int question: str | None = None - wiki_id: int | None = None - heading_path: str | None = None + wiki_id: int | None = None # primary wiki id (from first source) + heading_path: str | None = None # primary heading (from first source) + + @property + def source_path(self) -> str: + """Primary source path — kept for the legacy column / display.""" + return self.source_paths[0] if self.source_paths else "" diff --git a/src/heta/mem/prompts.py b/src/heta/mem/prompts.py index a7e06e3..2b9f8e8 100644 --- a/src/heta/mem/prompts.py +++ b/src/heta/mem/prompts.py @@ -94,40 +94,41 @@ - Do NOT include a Sources or References section. """ -KB_INSIGHT_EXTRACTION_PROMPT = """\ -You are a knowledge distillation engine. Given a user question and a KB page, extract concise, reusable insights from the page that are directly useful for answering the question. +INSIGHT_DEDUP_PROMPT = """\ +You are a memory deduplication judge for a retrieval cache of factual insights. -Process: -1. Reason step by step: which parts of the KB content are relevant to the question? -2. Distill those parts into self-contained insight statements. -3. Output ONLY the final JSON — no other text. +Given a NEW insight and a list of EXISTING similar insights already stored in memory, +decide whether the new insight should be skipped as redundant. -Schema: -{"insights": ["insight 1", "insight 2", ...]} +Return STRICT JSON only — no markdown, no commentary. +Schema: {"duplicate": true} OR {"duplicate": false} -Rules: -- Each insight must be self-contained and understandable without the original page. -- Only include insights relevant to the question — filter out unrelated content. -- LANGUAGE RULE: write insights in the SAME language as the question. -- Aim for 3–8 insights. Do not pad with trivial or redundant statements. -- If the page contains nothing relevant, return {"insights": []}. -""" +Decision rule: +A new insight is REDUNDANT (duplicate=true) if every factual element it +asserts — every entity, relationship, attribute, time, and place — is +already covered by the COMBINATION of existing insights. The new insight +does not need to be a paraphrase of any single existing one; what matters +is whether any genuinely new fact is being introduced. -INSIGHT_DEDUP_PROMPT = """\ -You are a memory deduplication judge. +A new insight is WORTH KEEPING (duplicate=false) if it introduces at least +one factual element not expressed by the existing set. -Given a NEW insight and a list of EXISTING similar insights already stored in memory, -decide whether the new insight is already fully covered and would be redundant to store. +Examples: +- NEW: "Martha Mattie 是 MJ 的祖母,青年时期生活在 Russell County" + EXISTING: ["Martha Mattie 是 MJ 的祖母", + "Martha Mattie 青年时期生活在 Russell County"] + → {"duplicate": true} (every fact already covered by the combination) -Return STRICT JSON only. No markdown, no extra text. -Schema: {"duplicate": true} OR {"duplicate": false} +- NEW: "Martha Mattie 是 MJ 的祖母,她的丈夫名叫 Samuel" + EXISTING: ["Martha Mattie 是 MJ 的祖母"] + → {"duplicate": false} (introduces "Samuel as husband" — a new fact) -Rules: -- Return {"duplicate": true} ONLY if an existing insight conveys the same fact or knowledge - as the new insight. A paraphrase of the same fact counts as duplicate. -- Return {"duplicate": false} if the new insight adds ANY information not present in the - existing ones, even if they are thematically related. -- When in doubt, return {"duplicate": false} — prefer storing over silently dropping. +- NEW: "John Doe 是诗人" + EXISTING: ["John Doe 是 20 世纪初居住在 Russell County 的诗人"] + → {"duplicate": true} (the existing insight already covers "John Doe 是诗人") + +When in doubt, return {"duplicate": false} — information loss is harder +to recover than slight redundancy. """ CONFLICT_JUDGE_PROMPT = """\ diff --git a/src/heta/query/agent.py b/src/heta/query/agent.py index 4d88764..d9131e0 100644 --- a/src/heta/query/agent.py +++ b/src/heta/query/agent.py @@ -11,7 +11,7 @@ from heta.config.schema import HetaConfig from heta.kb.agent import AgentStats, _chat_completion, _get_client -from heta.query.models import QueryResult, QuerySource, VectorMatch +from heta.query.models import QueryInsight, QueryResult, QuerySource, VectorMatch from heta.query.tools import ( format_vector_matches, read_index, @@ -84,6 +84,7 @@ class FinalAnswer: answer: str sources: list[QuerySource] + insights: list[QueryInsight] valid_json: bool = True @@ -148,7 +149,8 @@ def run_query_agent( "role": "user", "content": ( "Your previous response was not valid JSON. Return exactly one valid JSON object now, " - "with keys answer and used_sources. Do not include Markdown fences or text outside JSON." + "with keys insights, answer, and used_sources. Do not include Markdown fences or text " + "outside JSON." ), } ) @@ -158,6 +160,7 @@ def run_query_agent( return QueryResult( answer=final_answer.answer, sources=final_answer.sources, + insights=final_answer.insights, usage=stats.finish("completed"), ) @@ -203,6 +206,7 @@ def run_query_agent( return QueryResult( answer=final_answer.answer, sources=final_answer.sources, + insights=final_answer.insights, usage=stats.finish("stopped at limit"), ) @@ -218,22 +222,57 @@ def _system_prompt(vector_enabled: bool) -> str: Answer the user's question using the Little Heta wiki. You can inspect the wiki, but you must not create, edit, delete, rename, or commit anything. -Rules: +Reading rules: - Treat index.md as the global map of pages, ids, paths, and summaries. - Treat semantic matches as starting evidence, not final truth. - If a chunk is relevant but incomplete, call read_page(path) for the full page. - You may call read_raw(path) only for original raw files referenced by wiki pages. - Raw files help inspect details, but raw files must never appear in used_sources. + Raw files help inspect details, but raw files must never appear in used_sources or + insight source_paths. - Follow useful [[Wiki Links]] by reading the linked pages when the index gives their paths. {vector_rule} - Stop reading when the context is enough. -- If the wiki does not contain enough evidence, say what is missing. -- Your final response must be exactly one valid JSON object, with no Markdown fence: - {{"answer": "Markdown answer text", "used_sources": [{{"path": "pages/example.md", "heading_path": "Content > Section"}}]}} -- In used_sources, include only evidence you actually used. You may cite a semantic - match directly when its chunk is sufficient; use its exact path and heading. -- Do not include a Sources, References, or Citations section in answer. - The CLI renders validated sources separately. + +Output protocol — distill, then answer: +1. After gathering evidence, emit a list of `insights`: short, self-contained + factual claims distilled from the pages you read, each tagged with the + wiki page(s) it derives from. Insights are what the answer must rest on. +2. Compose the `answer` using ONLY facts that are present in your `insights`. + If a sentence in the answer needs a fact not yet represented in any + insight, you must add that fact as an insight first. +3. If the wiki does not contain enough evidence, return an empty insights + list and say in the answer what is missing. + +What makes a GOOD insight: +- ONE self-contained factual claim per insight. A claim may be compound + (multiple linked entities in one sentence) as long as it asserts a single + coherent fact — but never bundle two independent claims. +- Embed named entities (people, places, dates, organisations) directly in + the text. No pronouns, no "this", no "the above". +- A reader with no other context must understand exactly what is asserted. +- Only facts explicitly stated in the wiki — no inference, no speculation. + +source_paths rules: +- Each insight's source_paths lists the wiki page(s) that the insight + derives from. Use the exact page paths (e.g. "pages/12-foo.md"). +- A cross-page synthesis may list multiple paths. +- Every path in source_paths must be a page you actually read or that + appeared in semantic matches. + +Output format — exactly one valid JSON object, no Markdown fences: +{{ + "insights": [ + {{"text": "self-contained factual claim", "source_paths": ["pages/example.md"]}} + ], + "answer": "Markdown answer text, derived from the insights above", + "used_sources": [{{"path": "pages/example.md", "heading_path": "Section"}}] +}} + +- insights: emit as many as the question requires; no minimum, no maximum. + Return [] if no evidence supports an answer. +- used_sources: include only pages you actually relied on (same as the union + of insight source_paths). +- Do not include a Sources, References, or Citations section inside answer. """ @@ -310,17 +349,23 @@ def _parse_final_answer( ) -> FinalAnswer: data = _extract_json_object(text) if data is None: - return FinalAnswer(answer=text, sources=[], valid_json=False) + return FinalAnswer(answer=text, sources=[], insights=[], valid_json=False) answer = data.get("answer") used_sources = data.get("used_sources") + raw_insights = data.get("insights") if not isinstance(answer, str): answer = text if not isinstance(used_sources, list): used_sources = [] + if not isinstance(raw_insights, list): + raw_insights = [] sources: dict[str, QuerySource] = {} normalized_read_paths = {_normalize_candidate_path(path) for path in read_paths} + valid_paths = set(normalized_read_paths) + valid_paths.update(path for path, _ in vector_matches.keys()) + for source in used_sources: if not isinstance(source, dict): continue @@ -342,7 +387,41 @@ def _parse_final_answer( else: continue sources[f"{path}#{display_heading or ''}"] = source_from_page_path(path, base_dir, heading_path=display_heading) - return FinalAnswer(answer=answer, sources=list(sources.values())) + + insights = _validate_insights(raw_insights, valid_paths=valid_paths) + return FinalAnswer(answer=answer, sources=list(sources.values()), insights=insights) + + +def _validate_insights( + raw_insights: list, + *, + valid_paths: set[str], +) -> list[QueryInsight]: + """Validate each insight: keep ones with non-empty text and at least one valid source_path.""" + out: list[QueryInsight] = [] + for item in raw_insights: + if not isinstance(item, dict): + continue + text = item.get("text") + if not isinstance(text, str) or not text.strip(): + continue + raw_paths = item.get("source_paths") + if not isinstance(raw_paths, list): + continue + validated_paths: list[str] = [] + for p in raw_paths: + if not isinstance(p, str): + continue + try: + norm = _normalize_candidate_path(p) + except ValueError: + continue + if norm in valid_paths: + validated_paths.append(norm) + if not validated_paths: + continue + out.append(QueryInsight(text=text.strip(), source_paths=validated_paths)) + return out def _normalize_candidate_path(path: str) -> str: diff --git a/src/heta/query/models.py b/src/heta/query/models.py index b66ef72..e893035 100644 --- a/src/heta/query/models.py +++ b/src/heta/query/models.py @@ -24,9 +24,17 @@ class QuerySource: heading_path: str | None = None +@dataclass(frozen=True) +class QueryInsight: + """A distilled knowledge nugget emitted by the KB agent alongside its answer.""" + text: str + source_paths: list[str] + + @dataclass(frozen=True) class QueryResult: answer: str sources: list[QuerySource] = field(default_factory=list) + insights: list[QueryInsight] = field(default_factory=list) usage: dict | None = None diff --git a/src/heta/query/smart_query.py b/src/heta/query/smart_query.py index 0ffad6c..280374c 100644 --- a/src/heta/query/smart_query.py +++ b/src/heta/query/smart_query.py @@ -227,10 +227,11 @@ def _exec_query_kb(question: str, config: HetaConfig, top_k: int, base_dir: Path state.used_kb = True state.agent_steps.append("query_kb") - if _kb_has_info(kb_result.answer) and kb_result.sources: + if _kb_has_info(kb_result.answer) and kb_result.insights: try: state.written_back = remember_kb_insights( question=question, + insights=kb_result.insights, sources=kb_result.sources, config=config, base_dir=base_dir, diff --git a/tests/test_kb_invalidate.py b/tests/test_kb_invalidate.py index 9d95241..cc1e4d1 100644 --- a/tests/test_kb_invalidate.py +++ b/tests/test_kb_invalidate.py @@ -28,7 +28,10 @@ def _now() -> int: return int(time.time()) -def _insert_insight(conn, source_path: str, insight_text: str = "fact") -> str: +def _insert_insight(conn, source_paths, insight_text: str = "fact") -> str: + """source_paths can be a single str (for backward-compat tests) or a list.""" + if isinstance(source_paths, str): + source_paths = [source_paths] mid = str(uuid.uuid4()) insert_meta(conn, MemoryMeta( memory_id=mid, memory_type="kb_insight", session_id=None, @@ -36,7 +39,7 @@ def _insert_insight(conn, source_path: str, insight_text: str = "fact") -> str: )) insert_kb_insight(conn, KBInsight( memory_id=mid, insight=insight_text, question="q", - source_path=source_path, wiki_id=None, heading_path=None, + source_paths=source_paths, wiki_id=None, heading_path=None, created_at=_now(), )) # 1024-dim float embedding (matches EMBEDDING_DIM) @@ -105,6 +108,21 @@ def test_delete_all_on_empty_db_returns_zero(conn): assert delete_all_insights(conn) == 0 +def test_delete_by_paths_invalidates_multi_source_insight(conn): + """An insight derived from multiple pages dies when ANY of its sources changes.""" + multi = _insert_insight(conn, ["pages/1-foo.md", "pages/2-bar.md"]) + solo = _insert_insight(conn, ["pages/3-baz.md"]) + + deleted = delete_insights_by_paths(conn, ["pages/2-bar.md"]) + + assert deleted == 1 + remaining = [r[0] for r in conn.execute("SELECT memory_id FROM kb_insight").fetchall()] + assert multi not in remaining + assert solo in remaining + # both rows in kb_insight_source for the multi insight should be gone + assert _count(conn, "kb_insight_source") == 1 + + def test_delete_by_paths_preserves_other_memory_types(conn): """Deleting kb_insight by path must not touch L1/L2/etc.""" _insert_insight(conn, "pages/1-foo.md") From cf1682e0485fd7a2f04bf8cd52caedb97c2dc8f3 Mon Sep 17 00:00:00 2001 From: 77one Date: Fri, 15 May 2026 14:20:37 +0800 Subject: [PATCH 28/44] feat: support mineru office documents --- README.md | 6 +- src/heta/cli/init.py | 4 +- src/heta/kb/discovery.py | 2 +- src/heta/kb/parser.py | 110 +++++++++++++++++++++++++----------- tests/test_kb_insert.py | 23 ++++++++ tests/test_mineru_parser.py | 98 ++++++++++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 38 deletions(-) create mode 100644 tests/test_mineru_parser.py diff --git a/README.md b/README.md index 94fafab..6b37f52 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ and optional vector indexing. - Interactive first-time setup with `heta init` - Provider configuration for Qwen, ChatGPT, or Gemini -- Optional MinerU integration for PDF parsing +- Optional MinerU integration for PDF and Office document parsing - Markdown wiki generation under the Little Heta workspace - Stable numeric wiki page ids in page filenames - Optional SQLite + sqlite-vec wiki chunk index @@ -61,6 +61,10 @@ Insert one file or a directory: heta insert ./docs ``` +Supported source types include Markdown/text, images, audio/video, HTML, +common code files, PDF, and Office documents (`.doc`, `.docx`, `.ppt`, `.pptx`, +`.xls`, `.xlsx`). PDF and Office parsing require MinerU. + Large PDFs are profiled and split before parsing by default. Little Heta gives a lightweight PDF profile to a planning agent, validates the returned page ranges, and falls back to fixed page windows when planning is unavailable. Disable this diff --git a/src/heta/cli/init.py b/src/heta/cli/init.py index d31cfa7..ed2564c 100644 --- a/src/heta/cli/init.py +++ b/src/heta/cli/init.py @@ -149,7 +149,7 @@ def _configure_llm() -> LLMConfig: def _configure_mineru() -> MinerUConfig: console.print() - console.print(f"[{WARN}]?[/] Enable PDF parsing with MinerU?") + console.print(f"[{WARN}]?[/] Enable PDF and Office parsing with MinerU?") console.print(f" [{HETA}]1[/] Cloud") console.print(f" [{HETA}]2[/] Local sidecar") console.print(f" [{HETA}]3[/] Skip for now") @@ -284,7 +284,7 @@ def _show_summary(config: HetaConfig) -> None: table.add_column() table.add_row("config", str(CONFIG_PATH)) table.add_row("provider", config.llm.provider) - table.add_row("pdf", _mineru_summary(config.mineru)) + table.add_row("mineru docs", _mineru_summary(config.mineru)) table.add_row("next", f"[bold {HETA}]heta insert ./notes[/] or [bold {HETA}]heta remember \"...\"[/]") console.print( diff --git a/src/heta/kb/discovery.py b/src/heta/kb/discovery.py index 1670284..2a99014 100644 --- a/src/heta/kb/discovery.py +++ b/src/heta/kb/discovery.py @@ -9,7 +9,7 @@ from heta.kb.html_parser import HTML_EXTENSIONS PLAIN_EXTENSIONS = {".md", ".markdown", ".txt"} -MINERU_EXTENSIONS = {".pdf"} +MINERU_EXTENSIONS = {".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx"} IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".webm", ".mp4"} diff --git a/src/heta/kb/parser.py b/src/heta/kb/parser.py index d4757ea..07ff21d 100644 --- a/src/heta/kb/parser.py +++ b/src/heta/kb/parser.py @@ -2,7 +2,10 @@ from __future__ import annotations +import mimetypes import time +import zipfile +from io import BytesIO from pathlib import Path import requests @@ -10,6 +13,7 @@ from heta.config.schema import HetaConfig from heta.kb.audio_parser import AUDIO_EXTENSIONS, parse_audio_markdown from heta.kb.code_parser import CODE_EXTENSIONS, parse_code_markdown +from heta.kb.discovery import MINERU_EXTENSIONS from heta.kb.html_parser import HTML_EXTENSIONS, parse_html_markdown from heta.kb.image_parser import IMAGE_EXTENSIONS, parse_image_markdown from heta.kb.models import ParsedDocument @@ -20,8 +24,8 @@ def parse_document(source_path: Path, archived_path: Path, config: HetaConfig) - suffix = source_path.suffix.lower() if suffix in {".md", ".markdown", ".txt"}: markdown = source_path.read_text(encoding="utf-8") - elif suffix == ".pdf": - markdown = _parse_pdf_with_mineru(archived_path, config) + elif suffix in MINERU_EXTENSIONS: + markdown = _parse_with_mineru(archived_path, config) elif suffix in IMAGE_EXTENSIONS: markdown = parse_image_markdown(source_path, archived_path, config) elif suffix in AUDIO_EXTENSIONS: @@ -44,20 +48,21 @@ def parse_document(source_path: Path, archived_path: Path, config: HetaConfig) - ) -def _parse_pdf_with_mineru(path: Path, config: HetaConfig) -> str: +def _parse_with_mineru(path: Path, config: HetaConfig) -> str: if not config.mineru.enable: - raise ValueError(f"PDF parsing requires MinerU: {path.name}") + raise ValueError(f"Document parsing requires MinerU: {path.name}") if config.mineru.provider == "local": - return _parse_pdf_with_local_mineru(path, config.mineru.endpoint or "") + return _parse_with_local_mineru(path, config.mineru.endpoint or "") if config.mineru.provider == "cloud": - return _parse_pdf_with_cloud_mineru(path) + return _parse_with_cloud_mineru(path, config.mineru.api_key or "") raise ValueError("Invalid MinerU configuration.") -def _parse_pdf_with_local_mineru(path: Path, endpoint: str) -> str: +def _parse_with_local_mineru(path: Path, endpoint: str) -> str: url = endpoint.rstrip("/") + "/file_parse" + content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" with path.open("rb") as file: - response = requests.post(url, files={"file": (path.name, file, "application/pdf")}, timeout=300) + response = requests.post(url, files={"file": (path.name, file, content_type)}, timeout=300) if response.status_code != 200: raise RuntimeError(f"MinerU local parse failed: HTTP {response.status_code}") @@ -79,15 +84,23 @@ def _parse_pdf_with_local_mineru(path: Path, endpoint: str) -> str: return response.text -def _parse_pdf_with_cloud_mineru(path: Path) -> str: +def _parse_with_cloud_mineru(path: Path, api_key: str) -> str: + if not api_key.strip(): + raise ValueError("MinerU cloud parsing requires api_key.") + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Accept": "*/*", + } create_response = requests.post( - "https://mineru.net/api/v1/agent/parse/file", + "https://mineru.net/api/v4/file-urls/batch", + headers=headers, json={ - "file_name": path.name, + "files": [{"name": path.name, "data_id": path.stem}], "language": "ch", "enable_table": True, - "is_ocr": False, "enable_formula": True, + "model_version": "vlm", }, timeout=30, ) @@ -97,44 +110,73 @@ def _parse_pdf_with_cloud_mineru(path: Path) -> str: payload = create_response.json() if payload.get("code") != 0: raise RuntimeError(f"MinerU cloud task creation failed: {payload.get('msg')}") - task_id = payload.get("data", {}).get("task_id") - file_url = payload.get("data", {}).get("file_url") - if not task_id or not file_url: - raise RuntimeError("MinerU cloud did not return task_id and file_url.") + batch_id = payload.get("data", {}).get("batch_id") + file_urls = payload.get("data", {}).get("file_urls") + if not batch_id or not isinstance(file_urls, list) or not file_urls: + raise RuntimeError("MinerU cloud did not return batch_id and file_urls.") with path.open("rb") as file: - upload_response = requests.put(file_url, data=file, timeout=120) - if upload_response.status_code not in {200, 204}: + upload_response = requests.put(file_urls[0], data=file, timeout=120) + if upload_response.status_code not in {200, 201, 204}: raise RuntimeError(f"MinerU cloud upload failed: HTTP {upload_response.status_code}") - markdown_url = _poll_mineru_markdown_url(task_id) - markdown_response = requests.get(markdown_url, timeout=60) - if markdown_response.status_code != 200: - raise RuntimeError(f"MinerU markdown download failed: HTTP {markdown_response.status_code}") - markdown = markdown_response.text.strip() + zip_url = _poll_mineru_zip_url(batch_id, headers=headers, file_name=path.name) + zip_response = requests.get(zip_url, timeout=120) + if zip_response.status_code != 200: + raise RuntimeError(f"MinerU zip download failed: HTTP {zip_response.status_code}") + markdown = _extract_mineru_markdown(zip_response.content).strip() if not markdown: raise RuntimeError("MinerU cloud returned empty markdown.") return markdown -def _poll_mineru_markdown_url(task_id: str, *, timeout_seconds: int = 180) -> str: +def _poll_mineru_zip_url( + batch_id: str, + *, + headers: dict[str, str], + file_name: str, + timeout_seconds: int = 300, +) -> str: deadline = time.time() + timeout_seconds - url = f"https://mineru.net/api/v1/agent/parse/{task_id}" + url = f"https://mineru.net/api/v4/extract-results/batch/{batch_id}" while time.time() < deadline: - response = requests.get(url, timeout=30) + response = requests.get(url, headers=headers, timeout=30) if response.status_code != 200: raise RuntimeError(f"MinerU cloud polling failed: HTTP {response.status_code}") payload = response.json() if payload.get("code") != 0: raise RuntimeError(f"MinerU cloud polling failed: {payload.get('msg')}") - data = payload.get("data", {}) - state = data.get("state") + result = _mineru_batch_result(payload, file_name=file_name) + state = result.get("state") if state == "done": - markdown_url = data.get("markdown_url") - if not markdown_url: - raise RuntimeError("MinerU cloud result did not include markdown_url.") - return markdown_url + zip_url = result.get("full_zip_url") + if not zip_url: + raise RuntimeError("MinerU cloud result did not include full_zip_url.") + return zip_url if state == "failed": - raise RuntimeError(f"MinerU cloud parsing failed: {data.get('err_msg') or data.get('err_code')}") + raise RuntimeError(f"MinerU cloud parsing failed: {result.get('err_msg') or result.get('err_code')}") time.sleep(2) - raise TimeoutError(f"MinerU cloud parsing timed out after {timeout_seconds}s: {task_id}") + raise TimeoutError(f"MinerU cloud parsing timed out after {timeout_seconds}s: {batch_id}") + + +def _mineru_batch_result(payload: dict, *, file_name: str) -> dict: + results = payload.get("data", {}).get("extract_result") + if isinstance(results, list): + for result in results: + if isinstance(result, dict) and result.get("file_name") == file_name: + return result + if results and isinstance(results[0], dict): + return results[0] + return {} + + +def _extract_mineru_markdown(zip_content: bytes) -> str: + with zipfile.ZipFile(BytesIO(zip_content)) as archive: + names = archive.namelist() + for name in names: + if name.endswith("full.md"): + return archive.read(name).decode("utf-8") + for name in names: + if name.endswith(".md"): + return archive.read(name).decode("utf-8") + raise RuntimeError("MinerU zip did not include markdown output.") diff --git a/tests/test_kb_insert.py b/tests/test_kb_insert.py index ed02843..738d9d3 100644 --- a/tests/test_kb_insert.py +++ b/tests/test_kb_insert.py @@ -160,6 +160,29 @@ def test_pdf_requires_mineru_when_disabled(tmp_path: Path) -> None: collect_insert_files([source], _config()) +def test_office_requires_mineru_when_disabled(tmp_path: Path) -> None: + source = tmp_path / "deck.pptx" + source.write_bytes(b"pptx") + + with pytest.raises(ValueError, match="requires MinerU"): + collect_insert_files([source], _config()) + + +def test_collect_insert_files_accepts_office_when_mineru_enabled(tmp_path: Path) -> None: + files = [] + for name in ["notes.doc", "notes.docx", "deck.ppt", "deck.pptx", "sheet.xls", "sheet.xlsx"]: + file = tmp_path / name + file.write_bytes(b"office") + files.append(file) + + collected = collect_insert_files( + [tmp_path], + _config(MinerUConfig(enable=True, provider="cloud", api_key="mineru-token", endpoint=None)), + ) + + assert collected == sorted(files) + + def test_collect_insert_files_accepts_common_images(tmp_path: Path) -> None: image = tmp_path / "diagram.png" image.write_bytes(b"png") diff --git a/tests/test_mineru_parser.py b/tests/test_mineru_parser.py new file mode 100644 index 0000000..196e4d5 --- /dev/null +++ b/tests/test_mineru_parser.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import zipfile +from io import BytesIO +from pathlib import Path + +from heta.config.schema import HetaConfig, InsertPlanningConfig, LLMConfig, MinerUConfig, VectorIndexConfig +from heta.kb.parser import parse_document + + +class _Response: + def __init__( + self, + *, + status_code: int = 200, + payload: dict | None = None, + content: bytes = b"", + text: str = "", + headers: dict[str, str] | None = None, + ) -> None: + self.status_code = status_code + self._payload = payload or {} + self.content = content + self.text = text + self.headers = headers or {} + + def json(self) -> dict: + return self._payload + + +def _config() -> HetaConfig: + return HetaConfig( + version=1, + llm=LLMConfig(provider="qwen", api_key="sk-test"), + mineru=MinerUConfig(enable=True, provider="cloud", api_key="mineru-token", endpoint=None), + vector_index=VectorIndexConfig(enable=False), + insert_planning=InsertPlanningConfig.enabled(), + ) + + +def test_parse_document_accepts_office_via_mineru_cloud(monkeypatch, tmp_path: Path) -> None: + source = tmp_path / "slides.pptx" + archived = tmp_path / "2026-05-15_slides.pptx" + source.write_bytes(b"pptx") + archived.write_bytes(b"pptx") + zip_bytes = _mineru_zip("# Slides\n\nParsed by MinerU.") + requests_seen: list[tuple[str, str]] = [] + + def post(url, **kwargs): + requests_seen.append(("POST", url)) + assert kwargs["headers"]["Authorization"] == "Bearer mineru-token" + assert kwargs["json"]["files"] == [{"name": "2026-05-15_slides.pptx", "data_id": "2026-05-15_slides"}] + assert kwargs["json"]["model_version"] == "vlm" + return _Response(payload={"code": 0, "data": {"batch_id": "batch-1", "file_urls": ["https://upload"]}}) + + def put(url, **kwargs): + requests_seen.append(("PUT", url)) + assert url == "https://upload" + return _Response(status_code=200) + + def get(url, **kwargs): + requests_seen.append(("GET", url)) + if url.endswith("/batch-1"): + assert kwargs["headers"]["Authorization"] == "Bearer mineru-token" + return _Response( + payload={ + "code": 0, + "data": { + "extract_result": [ + { + "file_name": "2026-05-15_slides.pptx", + "state": "done", + "full_zip_url": "https://result.zip", + } + ] + }, + } + ) + assert url == "https://result.zip" + return _Response(content=zip_bytes) + + monkeypatch.setattr("heta.kb.parser.requests.post", post) + monkeypatch.setattr("heta.kb.parser.requests.put", put) + monkeypatch.setattr("heta.kb.parser.requests.get", get) + + document = parse_document(source, archived, _config()) + + assert document.title == "Slides" + assert document.metadata["extension"] == ".pptx" + assert document.markdown_content == "# Slides\n\nParsed by MinerU." + assert [method for method, _ in requests_seen] == ["POST", "PUT", "GET", "GET"] + + +def _mineru_zip(markdown: str) -> bytes: + buffer = BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("full.md", markdown) + return buffer.getvalue() From cfc15c798cef43d5642868a422ace3652aa3956d Mon Sep 17 00:00:00 2001 From: 77one Date: Mon, 18 May 2026 11:38:45 +0800 Subject: [PATCH 29/44] feat: unify little heta CLI styling on the Heta blue theme Centralize the color palette in branding.py as the single source of truth, add a coral-red error color, and theme the typer --help screen. Recolor the four off-palette commands (ask, recall, remember, mem-clean) and switch the rest to import the shared colors. - ask: boxed result card with title, Answer label, and in-box sources - recall: add --debug flag; non-debug shows the answer plus friendly sources while debug shows ranking/reason/scored evidence; fix KeyError on the kb_insight layer and show a friendly note when evidence is thin - smart_query: the outer agent now identifies as Little Heta --- src/heta/cli/__init__.py | 3 + src/heta/cli/ask.py | 74 +++++++++++++++-------- src/heta/cli/branding.py | 63 ++++++++++++++++++-- src/heta/cli/clean.py | 6 +- src/heta/cli/init.py | 8 +-- src/heta/cli/insert.py | 6 +- src/heta/cli/insert_planning.py | 6 +- src/heta/cli/mem_clean.py | 17 +++--- src/heta/cli/mem_show.py | 5 +- src/heta/cli/query.py | 5 +- src/heta/cli/recall.py | 101 ++++++++++++++++++++++++++------ src/heta/cli/remember.py | 17 +++--- src/heta/cli/status.py | 5 +- src/heta/cli/vector.py | 6 +- src/heta/query/smart_query.py | 22 ++++++- 15 files changed, 242 insertions(+), 102 deletions(-) diff --git a/src/heta/cli/__init__.py b/src/heta/cli/__init__.py index ca5ff46..3cacf60 100644 --- a/src/heta/cli/__init__.py +++ b/src/heta/cli/__init__.py @@ -5,6 +5,7 @@ import typer from heta.cli.ask import ask_command +from heta.cli.branding import apply_typer_theme from heta.cli.clean import clean_command from heta.cli.mem_clean import mem_clean_command from heta.cli.mem_show import app as mem_show_app @@ -18,6 +19,8 @@ from heta.cli.status import status_command from heta.cli.vector import app as vector_app +apply_typer_theme() + app = typer.Typer( name="heta", help="Little Heta command line interface.", diff --git a/src/heta/cli/ask.py b/src/heta/cli/ask.py index 631dc65..3baaa13 100644 --- a/src/heta/cli/ask.py +++ b/src/heta/cli/ask.py @@ -8,15 +8,16 @@ from rich.panel import Panel from rich.text import Text +from heta.cli.branding import CYAN, ERR, HETA, MUTED, OK, WARN from heta.config.io import load_config from heta.query.smart_query import smart_query console = Console() _SOURCE_STYLES = { - "memory": ("● memory", "bold green"), - "kb": ("● KB", "bold cyan"), - "both": ("● memory + KB", "bold magenta"), + "memory": ("● memory", f"bold {OK}"), + "kb": ("● KB", f"bold {HETA}"), + "both": ("● memory + KB", f"bold {CYAN}"), } @@ -28,14 +29,14 @@ def ask_command( """Answer a question via an outer agent that decides between memory and the KB.""" config = load_config() if config is None: - console.print("[red]Heta is not initialised. Run `heta init` first.[/red]") + console.print(f"[{ERR}]Heta is not initialised. Run `heta init` first.[/]") raise typer.Exit(1) - with console.status("[cyan]Thinking...[/cyan]"): + with console.status(f"[bold {HETA}]Thinking...[/]"): result = smart_query(question, config, top_k=top_k) if debug: - console.print("\n[bold yellow]── DEBUG ──[/bold yellow]") + console.print(f"\n[bold {WARN}]── DEBUG ──[/]") console.print(f"agent steps: {' → '.join(result.agent_steps) or '(none)'}") if result.memory_evidence: @@ -65,23 +66,50 @@ def ask_command( console.print(f" [dim][{i}] sources:[/dim] {qi.source_paths}") console.print(f" {qi.text}") console.print(f" written_back: {result.written_back}") - console.print("[bold yellow]──────────[/bold yellow]\n") + console.print(f"[bold {WARN}]──────────[/]\n") label, style = _SOURCE_STYLES[result.source] - header = Text() - header.append("Source: ") - header.append(label, style=style) + source_line = Text() + source_line.append("Source: ", style=f"bold {HETA}") + source_line.append(label, style=style) if result.written_back: - header.append(f" ({result.written_back} memories written back)", style="dim") - - console.print(header) - console.print() - console.print(Panel(Markdown(result.answer), border_style="cyan")) - - if result.kb_result and result.kb_result.sources: - console.print("[dim]KB Sources:[/dim]") - for src in result.kb_result.sources: - title = src.title or src.path - heading = f" — {src.heading_path}" if src.heading_path else "" - console.print(f" [dim][{src.wiki_id}][/dim] {title}{heading}") - console.print() + source_line.append(f" ({result.written_back} memories written back)", style=MUTED) + + console.print( + Panel( + _AnswerRenderable(Markdown(result.answer), source_line, _kb_sources_text(result)), + title="ask", + border_style=HETA, + padding=(1, 2), + ) + ) + + +def _kb_sources_text(result) -> Text: + text = Text() + if not (result.kb_result and result.kb_result.sources): + return text + for src in result.kb_result.sources: + title = src.title or src.path + heading = f" — {src.heading_path}" if src.heading_path else "" + text.append(f"[{src.wiki_id}] ", style=MUTED) + text.append(f"{title}{heading}\n") + text.rstrip() + return text + + +class _AnswerRenderable: + def __init__(self, answer: Markdown, source: Text, kb_sources: Text) -> None: + self.answer = answer + self.source = source + self.kb_sources = kb_sources + + def __rich_console__(self, console: Console, options): + yield Text("Answer:", style=f"bold {HETA}") + yield self.answer + yield Text("") + yield self.source + if self.kb_sources.plain: + yield Text("") + yield Text("KB Sources:", style=f"bold {HETA}") + yield self.kb_sources diff --git a/src/heta/cli/branding.py b/src/heta/cli/branding.py index 0bcd959..c1cf60a 100644 --- a/src/heta/cli/branding.py +++ b/src/heta/cli/branding.py @@ -1,13 +1,21 @@ -"""Shared Little Heta CLI branding.""" +"""Shared Little Heta CLI branding and color palette. + +Single source of truth for the Heta blue color family. Every CLI command +imports its colors from here so the product keeps one consistent look. +""" from __future__ import annotations from heta import __version__ -HETA = "rgb(52,144,220)" -CYAN = "rgb(88,196,220)" -OK = "rgb(76,196,142)" -MUTED = "rgb(126,146,158)" +# --- Heta blue palette --- +HETA = "rgb(52,144,220)" # primary blue — command names, arrows, panel borders +HETA_DARK = "rgb(31,91,156)" # deep blue — secondary emphasis +CYAN = "rgb(88,196,220)" # cyan accent — blended / secondary highlights +OK = "rgb(76,196,142)" # green — success +WARN = "rgb(238,183,74)" # amber — warnings, prompts +ERR = "rgb(224,108,108)" # coral red — errors, destructive markers +MUTED = "rgb(126,146,158)" # slate gray — secondary text APP_TITLE = "Little Heta" APP_TAGLINE = "Personal knowledge, memory, and document intelligence CLI" @@ -22,4 +30,47 @@ def brand_line() -> str: ) -__all__ = ["APP_TAGLINE", "APP_TEAM", "APP_TITLE", "brand_line"] +def apply_typer_theme() -> None: + """Re-skin Typer's ``--help`` screen to the Heta blue palette. + + Typer reads these module-level style constants at render time, so + overriding them once at import keeps every command's help consistent. + """ + from typer import rich_utils as ru + + ru.STYLE_OPTION = f"bold {HETA}" + ru.STYLE_SWITCH = f"bold {OK}" + ru.STYLE_NEGATIVE_OPTION = f"bold {CYAN}" + ru.STYLE_NEGATIVE_SWITCH = f"bold {ERR}" + ru.STYLE_METAVAR = f"bold {CYAN}" + ru.STYLE_METAVAR_SEPARATOR = MUTED + ru.STYLE_USAGE = HETA + ru.STYLE_HELPTEXT = MUTED + ru.STYLE_OPTION_DEFAULT = MUTED + ru.STYLE_OPTION_ENVVAR = MUTED + ru.STYLE_REQUIRED_SHORT = ERR + ru.STYLE_REQUIRED_LONG = ERR + ru.STYLE_OPTIONS_PANEL_BORDER = HETA + ru.STYLE_COMMANDS_PANEL_BORDER = HETA + ru.STYLE_ERRORS_PANEL_BORDER = ERR + ru.STYLE_COMMANDS_TABLE_FIRST_COLUMN = f"bold {HETA}" + ru.STYLE_ABORTED = ERR + ru.STYLE_DEPRECATED = ERR + ru.STYLE_DEPRECATED_COMMAND = MUTED + ru.STYLE_ERRORS_SUGGESTION = MUTED + + +__all__ = [ + "APP_TAGLINE", + "APP_TEAM", + "APP_TITLE", + "CYAN", + "ERR", + "HETA", + "HETA_DARK", + "MUTED", + "OK", + "WARN", + "apply_typer_theme", + "brand_line", +] diff --git a/src/heta/cli/clean.py b/src/heta/cli/clean.py index 6b2d16d..e132771 100644 --- a/src/heta/cli/clean.py +++ b/src/heta/cli/clean.py @@ -10,17 +10,13 @@ from rich.prompt import Confirm from rich.table import Table +from heta.cli.branding import HETA, MUTED, OK, WARN from heta.config.io import CONFIG_PATH, load_config from heta.kb import paths from heta.kb.clean import CleanSummary, clean_knowledge_base console = Console() -HETA = "rgb(52,144,220)" -MUTED = "rgb(126,146,158)" -OK = "rgb(76,196,142)" -WARN = "rgb(238,183,74)" - def clean_command( yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), diff --git a/src/heta/cli/init.py b/src/heta/cli/init.py index ed2564c..ffdb35b 100644 --- a/src/heta/cli/init.py +++ b/src/heta/cli/init.py @@ -14,7 +14,7 @@ from rich.prompt import Confirm, IntPrompt, Prompt from rich.table import Table -from heta.cli.branding import APP_TAGLINE, brand_line +from heta.cli.branding import APP_TAGLINE, HETA, MUTED, OK, WARN, brand_line from heta.config.io import CONFIG_PATH, save_config from heta.config.schema import HetaConfig, InsertPlanningConfig, LLMConfig, MinerUConfig, VectorIndexConfig from heta.providers.llm import validate_llm @@ -22,12 +22,6 @@ console = Console() -HETA = "rgb(52,144,220)" -HETA_DARK = "rgb(31,91,156)" -MUTED = "rgb(126,146,158)" -OK = "rgb(76,196,142)" -WARN = "rgb(238,183,74)" - LLM_PROVIDERS = {1: "qwen", 2: "chatgpt", 3: "gemini"} MINERU_OPTIONS = {1: "cloud", 2: "local", 3: "skip"} MAX_RETRIES = 3 diff --git a/src/heta/cli/insert.py b/src/heta/cli/insert.py index 5992aa7..3ef9381 100644 --- a/src/heta/cli/insert.py +++ b/src/heta/cli/insert.py @@ -10,6 +10,7 @@ from rich.progress import BarColumn, Progress, TaskProgressColumn, TextColumn from rich.table import Table +from heta.cli.branding import HETA, MUTED, OK, WARN from heta.config.io import CONFIG_PATH, load_config from heta.kb import paths from heta.kb.discovery import collect_insert_files, supported_extensions @@ -19,11 +20,6 @@ console = Console() -HETA = "rgb(52,144,220)" -MUTED = "rgb(126,146,158)" -OK = "rgb(76,196,142)" -WARN = "rgb(238,183,74)" - def insert_command( targets: list[Path] = typer.Argument( diff --git a/src/heta/cli/insert_planning.py b/src/heta/cli/insert_planning.py index 60ebfc7..7ba5af5 100644 --- a/src/heta/cli/insert_planning.py +++ b/src/heta/cli/insert_planning.py @@ -7,16 +7,12 @@ import typer from rich.console import Console +from heta.cli.branding import HETA, MUTED, OK, WARN from heta.config.io import CONFIG_PATH, load_config, save_config from heta.config.schema import InsertPlanningConfig console = Console() -HETA = "rgb(52,144,220)" -MUTED = "rgb(126,146,158)" -OK = "rgb(76,196,142)" -WARN = "rgb(238,183,74)" - app = typer.Typer( name="insert-planning", help="Manage Little Heta insert planning loops.", diff --git a/src/heta/cli/mem_clean.py b/src/heta/cli/mem_clean.py index 4d0b21d..6a96d55 100644 --- a/src/heta/cli/mem_clean.py +++ b/src/heta/cli/mem_clean.py @@ -6,6 +6,7 @@ from rich.console import Console from rich.prompt import Confirm +from heta.cli.branding import MUTED, OK from heta.mem.clean import clean_memory from heta.mem.db import get_connection, init_db from heta.mem.paths import db_path, ensure_mem_dir @@ -21,7 +22,7 @@ def mem_clean_command( "Delete all memory data? This cannot be undone.", default=False, ): - console.print("[dim]Cancelled.[/dim]") + console.print(f"[{MUTED}]Cancelled.[/]") raise typer.Exit(0) ensure_mem_dir() @@ -30,10 +31,10 @@ def mem_clean_command( result = clean_memory(conn) conn.close() - console.print("[green]✓[/green] Memory cleared.") - console.print(f" [dim]sessions:[/dim] {result.deleted_sessions}") - console.print(f" [dim]L0 turns:[/dim] {result.deleted_l0_turns}") - console.print(f" [dim]L1 episodes:[/dim] {result.deleted_l1_episodes}") - console.print(f" [dim]L2 facts:[/dim] {result.deleted_l2_facts}") - console.print(f" [dim]KB insights:[/dim] {result.deleted_kb_insights}") - console.print(f" [dim]memory_meta rows:[/dim] {result.deleted_meta}") + console.print(f"[{OK}]✓[/] Memory cleared.") + console.print(f" [{MUTED}]sessions:[/] {result.deleted_sessions}") + console.print(f" [{MUTED}]L0 turns:[/] {result.deleted_l0_turns}") + console.print(f" [{MUTED}]L1 episodes:[/] {result.deleted_l1_episodes}") + console.print(f" [{MUTED}]L2 facts:[/] {result.deleted_l2_facts}") + console.print(f" [{MUTED}]KB insights:[/] {result.deleted_kb_insights}") + console.print(f" [{MUTED}]memory_meta rows:[/] {result.deleted_meta}") diff --git a/src/heta/cli/mem_show.py b/src/heta/cli/mem_show.py index 8ae7862..d0ed50b 100644 --- a/src/heta/cli/mem_show.py +++ b/src/heta/cli/mem_show.py @@ -9,15 +9,12 @@ from rich.console import Console from rich.table import Table +from heta.cli.branding import HETA, MUTED, WARN from heta.mem.db import get_connection, init_db from heta.mem.paths import db_path console = Console() -HETA = "rgb(52,144,220)" -MUTED = "rgb(126,146,158)" -WARN = "rgb(238,183,74)" - app = typer.Typer( name="mem-show", help="Inspect stored memory contents.", diff --git a/src/heta/cli/query.py b/src/heta/cli/query.py index 07cb8ca..ad60b9a 100644 --- a/src/heta/cli/query.py +++ b/src/heta/cli/query.py @@ -8,15 +8,12 @@ from rich.panel import Panel from rich.text import Text +from heta.cli.branding import HETA, MUTED, WARN from heta.config.io import CONFIG_PATH, load_config from heta.query import QueryResult, run_wiki_query console = Console() -HETA = "rgb(52,144,220)" -MUTED = "rgb(126,146,158)" -WARN = "rgb(238,183,74)" - def query_command( question: str = typer.Argument(..., help="Question to answer from the Little Heta wiki."), diff --git a/src/heta/cli/recall.py b/src/heta/cli/recall.py index b07f13a..c289e11 100644 --- a/src/heta/cli/recall.py +++ b/src/heta/cli/recall.py @@ -4,58 +4,125 @@ import typer from rich.console import Console +from rich.padding import Padding from rich.panel import Panel from rich.text import Text +from heta.cli.branding import ERR, HETA, MUTED, WARN from heta.config.io import load_config from heta.mem.recall import recall console = Console() +# Technical layer names — shown in --debug output. _LAYER_LABELS = { "raw": "L0 Raw", "episode": "L1 Episode", "atomic_fact": "L2 Atomic Fact", + "kb_insight": "KB Insight", +} + +# User-facing layer names — shown in the recall result box. +_SOURCE_LABELS = { + "raw": "Conversation", + "episode": "Episodes", + "atomic_fact": "Facts", + "kb_insight": "Documents", } def recall_command( query: str = typer.Argument(..., help="What to recall."), top_k: int = typer.Option(10, "--top-k", "-k", help="Results per layer."), + debug: bool = typer.Option( + False, "--debug", "-d", help="Show layer ranking, reason, and scored evidence." + ), ) -> None: """Retrieve and rank memories relevant to a query.""" config = load_config() if config is None: - console.print("[red]Heta is not initialised. Run `heta init` first.[/red]") + console.print(f"[{ERR}]Heta is not initialised. Run `heta init` first.[/]") raise typer.Exit(1) - with console.status("[cyan]Searching memories...[/cyan]"): + with console.status(f"[bold {HETA}]Searching memories...[/]"): result = recall(query, config, top_k=top_k) - ranking_str = " > ".join(_LAYER_LABELS.get(r, r) for r in result.ranking) + if debug: + _show_debug(result) + + _show_result(result) + +def _show_result(result) -> None: lines = Text() - lines.append("Layer ranking: ", style="dim") - lines.append(ranking_str + "\n", style="bold cyan") - lines.append("Reason: ", style="dim") - lines.append(result.reason + "\n\n", style="italic") - lines.append(result.answer, style="white") + lines.append("Query: ", style=f"bold {HETA}") + lines.append(f'"{result.query}"\n\n') + + lines.append("Answer:\n", style=f"bold {HETA}") + if result.answer: + lines.append(result.answer) + else: + lines.append( + "I couldn't find a confident answer in your memories yet — " + "the most relevant pieces are listed below.", + style=MUTED, + ) + + source = _source_text(result) + if source.plain: + lines.append("\n\n") + lines.append("Source:\n", style=f"bold {HETA}") + lines.append(source) + + console.print(Panel(lines, title="recall", border_style=HETA, padding=(1, 2))) + + +def _source_text(result) -> Text: + text = Text() + for layer_ev in result.evidence: + if not layer_ev.items: + continue + label = _SOURCE_LABELS.get(layer_ev.layer, layer_ev.layer) + text.append(f"{label}\n", style=HETA) + for item in layer_ev.items: + text.append(" · ", style=HETA) + text.append(f"{_item_text(layer_ev.layer, item)}\n", style=MUTED) + text.rstrip() + return text + - console.print(Panel(lines, title=f'[bold]Recall: "{result.query}"[/bold]', border_style="cyan")) +def _show_debug(result) -> None: + ranking_str = " > ".join(_LAYER_LABELS.get(r, r) for r in result.ranking) + console.print(f"\n[bold {WARN}]── DEBUG ──[/]\n") + + console.print(f"[bold {HETA}]Ranking[/]") + console.print(f" {ranking_str}\n") + console.print(f"[bold {HETA}]Reason[/]") + console.print(Padding(Text(result.reason), (0, 0, 0, 2))) console.print() + + console.print(f"[bold {HETA}]Evidence[/]") for layer_ev in result.evidence: if not layer_ev.items: continue label = _LAYER_LABELS.get(layer_ev.layer, layer_ev.layer) - console.print(f"[bold]{label}[/bold]") + console.print(f" [{HETA}]{label}[/]") for item in layer_ev.items: score = item.get("score", 0) - if layer_ev.layer == "raw": - text = item["text_content"] - elif layer_ev.layer == "episode": - text = item["summary"] - else: - text = item["fact_text"] - console.print(f" [dim][score={score:.3f}][/dim] {text}") + line = Text(" ") + line.append(f"{score:.3f} · ", style=MUTED) + line.append(_item_text(layer_ev.layer, item)) + console.print(line) console.print() + console.print(f"[bold {WARN}]──────────[/]\n") + + +def _item_text(layer: str, item: dict) -> str: + if layer == "raw": + return item.get("text_content", "") + if layer == "episode": + return item.get("summary", "") + if layer == "kb_insight": + return item.get("insight", "") + return item.get("fact_text", "") diff --git a/src/heta/cli/remember.py b/src/heta/cli/remember.py index e08c047..7cfde29 100644 --- a/src/heta/cli/remember.py +++ b/src/heta/cli/remember.py @@ -6,6 +6,7 @@ from rich.console import Console from rich.panel import Panel +from heta.cli.branding import ERR, HETA, MUTED, OK from heta.config.io import load_config from heta.mem.pipeline import remember @@ -18,19 +19,19 @@ def remember_command( """Extract and store memories from a piece of text.""" config = load_config() if config is None: - console.print("[red]Heta is not initialised. Run `heta init` first.[/red]") + console.print(f"[{ERR}]Heta is not initialised. Run `heta init` first.[/]") raise typer.Exit(1) - with console.status("[cyan]Extracting memories...[/cyan]"): + with console.status(f"[bold {HETA}]Extracting memories...[/]"): result = remember(text, config) console.print( Panel( - f"[green]L1 episodes:[/green] {result.l1_count}\n" - f"[green]L2 facts:[/green] {result.l2_count}\n" - f"[dim]session: {result.session_id}[/dim]\n" - f"[dim]elapsed: {result.elapsed_s}s[/dim]", - title="[bold]Memory stored[/bold]", - border_style="green", + f"[bold {HETA}]L1 episodes:[/] {result.l1_count}\n" + f"[bold {HETA}]L2 facts:[/] {result.l2_count}\n" + f"[{MUTED}]session: {result.session_id}[/]\n" + f"[{MUTED}]elapsed: {result.elapsed_s}s[/]", + title="remember", + border_style=OK, ) ) diff --git a/src/heta/cli/status.py b/src/heta/cli/status.py index 10bb9fe..f7a7060 100644 --- a/src/heta/cli/status.py +++ b/src/heta/cli/status.py @@ -11,16 +11,13 @@ from rich.panel import Panel from rich.table import Table -from heta.cli.branding import brand_line +from heta.cli.branding import HETA, MUTED, WARN, brand_line from heta.config.io import CONFIG_PATH, load_config from heta.config.schema import HetaConfig, MinerUConfig from heta.kb import paths console = Console() -HETA = "rgb(52,144,220)" -MUTED = "rgb(126,146,158)" -WARN = "rgb(238,183,74)" BAR_FULL = "█" BAR_EMPTY = "░" BAR_WIDTH = 20 diff --git a/src/heta/cli/vector.py b/src/heta/cli/vector.py index f687285..2e51864 100644 --- a/src/heta/cli/vector.py +++ b/src/heta/cli/vector.py @@ -7,16 +7,12 @@ import typer from rich.console import Console +from heta.cli.branding import HETA, MUTED, OK, WARN from heta.config.io import CONFIG_PATH, load_config, save_config from heta.config.schema import VectorIndexConfig console = Console() -HETA = "rgb(52,144,220)" -MUTED = "rgb(126,146,158)" -OK = "rgb(76,196,142)" -WARN = "rgb(238,183,74)" - app = typer.Typer( name="vector", help="Manage Little Heta wiki vector indexing.", diff --git a/src/heta/query/smart_query.py b/src/heta/query/smart_query.py index 280374c..ae93737 100644 --- a/src/heta/query/smart_query.py +++ b/src/heta/query/smart_query.py @@ -4,6 +4,7 @@ import json import logging +import time from dataclasses import dataclass, field from pathlib import Path from typing import Any, Literal @@ -70,7 +71,8 @@ ] OUTER_SYSTEM_PROMPT = """\ -You are a personal assistant with access to two information sources via tools. +You are Little Heta, a knowledge management assistant with access to two +information sources via tools. Tools: - recall_memory(query): fast search over personal memory (past conversations, @@ -105,6 +107,7 @@ class SmartQueryResult: kb_result: QueryResult | None = None written_back: int = 0 agent_steps: list[str] = field(default_factory=list) + usage: dict[str, Any] | None = None @dataclass @@ -115,6 +118,8 @@ class _State: used_memory: bool = False used_kb: bool = False agent_steps: list[str] = field(default_factory=list) + outer_tokens: int = 0 + started_at: float = field(default_factory=time.time) def smart_query( @@ -130,6 +135,7 @@ def smart_query( for _ in range(MAX_OUTER_STEPS): resp = _chat(client, model, messages, tools=OUTER_TOOLS, config=config) + _record_outer_usage(state, resp) msg = resp.choices[0].message tool_calls = list(msg.tool_calls or []) @@ -158,6 +164,7 @@ def smart_query( {"role": "user", "content": "Step limit reached. Answer with the evidence already gathered, or say you don't know."} ) final = _chat(client, model, messages, tools=None, config=config) + _record_outer_usage(state, final) return _build_result(state, answer=final.choices[0].message.content or "") @@ -176,6 +183,12 @@ def _build_result(state: _State, *, answer: str) -> SmartQueryResult: kb_result=state.kb_result, written_back=state.written_back, agent_steps=list(state.agent_steps), + usage={ + "outer_tokens": state.outer_tokens, + "kb_tokens": (state.kb_result.usage or {}).get("tokens", 0) if state.kb_result else 0, + "tokens": state.outer_tokens + ((state.kb_result.usage or {}).get("tokens", 0) if state.kb_result else 0), + "elapsed_s": round(time.time() - state.started_at, 3), + }, ) @@ -256,6 +269,13 @@ def _chat(client, model: str, messages: list[dict[str, Any]], *, tools, config: return client.chat.completions.create(**kwargs) +def _record_outer_usage(state: _State, response: Any) -> None: + usage = getattr(response, "usage", None) + prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0 + completion_tokens = getattr(usage, "completion_tokens", 0) or 0 + state.outer_tokens += prompt_tokens + completion_tokens + + def _kb_has_info(answer: str) -> bool: lower = answer.lower() return not any(phrase in lower for phrase in _NO_INFO_PHRASES) From 89cdc87d6d4861fc4f849a111e8701179dab0196 Mon Sep 17 00:00:00 2001 From: 77one Date: Mon, 18 May 2026 11:43:11 +0800 Subject: [PATCH 30/44] docs: reword CLI command help in plain language Drop internal jargon (outer agent, KB, vector database, "Schema is preserved", read-only) from the command summaries shown by `heta --help` so first-time users can tell what each command does. --- src/heta/cli/__init__.py | 2 +- src/heta/cli/ask.py | 2 +- src/heta/cli/clean.py | 2 +- src/heta/cli/insert.py | 2 +- src/heta/cli/insert_planning.py | 2 +- src/heta/cli/mem_clean.py | 2 +- src/heta/cli/mem_show.py | 2 +- src/heta/cli/query.py | 2 +- src/heta/cli/recall.py | 2 +- src/heta/cli/remember.py | 2 +- src/heta/cli/status.py | 2 +- src/heta/cli/vector.py | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/heta/cli/__init__.py b/src/heta/cli/__init__.py index 3cacf60..314284e 100644 --- a/src/heta/cli/__init__.py +++ b/src/heta/cli/__init__.py @@ -36,7 +36,7 @@ def main() -> None: @app.command("init") def init_command() -> None: - """Run the first-time Little Heta initialization wizard.""" + """Set up Little Heta for the first time.""" try: interactive_init() except (KeyboardInterrupt, EOFError): diff --git a/src/heta/cli/ask.py b/src/heta/cli/ask.py index 3baaa13..fea43b9 100644 --- a/src/heta/cli/ask.py +++ b/src/heta/cli/ask.py @@ -26,7 +26,7 @@ def ask_command( top_k: int = typer.Option(5, "--top-k", "-k", help="Results per layer / KB vector match."), debug: bool = typer.Option(False, "--debug", "-d", help="Print agent steps, memory evidence, and KB output."), ) -> None: - """Answer a question via an outer agent that decides between memory and the KB.""" + """Ask anything — answered from your memory and documents.""" config = load_config() if config is None: console.print(f"[{ERR}]Heta is not initialised. Run `heta init` first.[/]") diff --git a/src/heta/cli/clean.py b/src/heta/cli/clean.py index e132771..24bda14 100644 --- a/src/heta/cli/clean.py +++ b/src/heta/cli/clean.py @@ -21,7 +21,7 @@ def clean_command( yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), ) -> None: - """Clean wiki knowledge pages and vector database while keeping raw files.""" + """Remove generated wiki pages (your original files are kept).""" config = load_config() if config is None: console.print(f"[{WARN}]?[/] Little Heta is not initialized.") diff --git a/src/heta/cli/insert.py b/src/heta/cli/insert.py index 3ef9381..5c09763 100644 --- a/src/heta/cli/insert.py +++ b/src/heta/cli/insert.py @@ -27,7 +27,7 @@ def insert_command( help="File or directory paths to insert. Defaults to the current directory.", ), ) -> None: - """Insert files into the Little Heta Markdown knowledge base.""" + """Add files to your knowledge base.""" config = load_config() if config is None: console.print(f"[{WARN}]?[/] Little Heta is not initialized.") diff --git a/src/heta/cli/insert_planning.py b/src/heta/cli/insert_planning.py index 7ba5af5..5b2666f 100644 --- a/src/heta/cli/insert_planning.py +++ b/src/heta/cli/insert_planning.py @@ -15,7 +15,7 @@ app = typer.Typer( name="insert-planning", - help="Manage Little Heta insert planning loops.", + help="Turn smart insert planning on or off.", no_args_is_help=True, rich_markup_mode="rich", ) diff --git a/src/heta/cli/mem_clean.py b/src/heta/cli/mem_clean.py index 6a96d55..71b99c7 100644 --- a/src/heta/cli/mem_clean.py +++ b/src/heta/cli/mem_clean.py @@ -17,7 +17,7 @@ def mem_clean_command( yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), ) -> None: - """Wipe all memory (personal + KB cache). Schema is preserved.""" + """Erase everything Little Heta has remembered.""" if not yes and not Confirm.ask( "Delete all memory data? This cannot be undone.", default=False, diff --git a/src/heta/cli/mem_show.py b/src/heta/cli/mem_show.py index d0ed50b..907bf4a 100644 --- a/src/heta/cli/mem_show.py +++ b/src/heta/cli/mem_show.py @@ -17,7 +17,7 @@ app = typer.Typer( name="mem-show", - help="Inspect stored memory contents.", + help="Browse the memories Little Heta has stored.", no_args_is_help=True, rich_markup_mode="rich", ) diff --git a/src/heta/cli/query.py b/src/heta/cli/query.py index ad60b9a..59affe3 100644 --- a/src/heta/cli/query.py +++ b/src/heta/cli/query.py @@ -19,7 +19,7 @@ def query_command( question: str = typer.Argument(..., help="Question to answer from the Little Heta wiki."), top_k: int = typer.Option(5, "--top-k", min=1, max=10, help="Initial vector matches to include."), ) -> None: - """Ask a read-only question against the Little Heta wiki.""" + """Ask a question about your inserted documents.""" config = load_config() if config is None: console.print(f"[{WARN}]?[/] Little Heta is not initialized.") diff --git a/src/heta/cli/recall.py b/src/heta/cli/recall.py index c289e11..ec7535f 100644 --- a/src/heta/cli/recall.py +++ b/src/heta/cli/recall.py @@ -38,7 +38,7 @@ def recall_command( False, "--debug", "-d", help="Show layer ranking, reason, and scored evidence." ), ) -> None: - """Retrieve and rank memories relevant to a query.""" + """Look up what Little Heta remembers.""" config = load_config() if config is None: console.print(f"[{ERR}]Heta is not initialised. Run `heta init` first.[/]") diff --git a/src/heta/cli/remember.py b/src/heta/cli/remember.py index 7cfde29..127ff59 100644 --- a/src/heta/cli/remember.py +++ b/src/heta/cli/remember.py @@ -16,7 +16,7 @@ def remember_command( text: str = typer.Argument(..., help="Text to remember."), ) -> None: - """Extract and store memories from a piece of text.""" + """Save something for Little Heta to remember.""" config = load_config() if config is None: console.print(f"[{ERR}]Heta is not initialised. Run `heta init` first.[/]") diff --git a/src/heta/cli/status.py b/src/heta/cli/status.py index f7a7060..8d1cf3e 100644 --- a/src/heta/cli/status.py +++ b/src/heta/cli/status.py @@ -36,7 +36,7 @@ class StatusSummary: def status_command() -> None: - """Show the current Little Heta status.""" + """Show what's set up and how much is stored.""" try: config = load_config() except Exception as exc: diff --git a/src/heta/cli/vector.py b/src/heta/cli/vector.py index 2e51864..a94c129 100644 --- a/src/heta/cli/vector.py +++ b/src/heta/cli/vector.py @@ -15,7 +15,7 @@ app = typer.Typer( name="vector", - help="Manage Little Heta wiki vector indexing.", + help="Turn document search vector indexing on or off.", no_args_is_help=True, rich_markup_mode="rich", ) From da20fc58705c749a3e120305f42b6e62cccd1ada Mon Sep 17 00:00:00 2001 From: 77one Date: Mon, 18 May 2026 15:03:12 +0800 Subject: [PATCH 31/44] chore: prepare pypi release --- .github/workflows/pypi-publish.yml | 36 +++ README.md | 223 ++++++++++++---- docs/assets/little-heta-banner.png | Bin 0 -> 960883 bytes docs/cli/ask.md | 22 ++ docs/cli/clean.md | 18 ++ docs/cli/init.md | 21 ++ docs/cli/insert-planning.md | 14 + docs/cli/insert.md | 26 ++ docs/cli/mem-clean.md | 12 + docs/cli/mem-show.md | 19 ++ docs/cli/query.md | 19 ++ docs/cli/recall.md | 19 ++ docs/cli/remember.md | 11 + docs/cli/skill.md | 24 ++ docs/cli/status.md | 18 ++ docs/cli/vector.md | 14 + docs/i18n/README.de.md | 54 ++++ docs/i18n/README.es.md | 54 ++++ docs/i18n/README.fr.md | 54 ++++ docs/i18n/README.ja.md | 54 ++++ docs/i18n/README.ko.md | 54 ++++ docs/i18n/README.pt.md | 54 ++++ docs/i18n/README.zh-CN.md | 54 ++++ docs/i18n/README.zh-TW.md | 54 ++++ .../pdf_planning_agent_flow.md | 244 ------------------ pyproject.toml | 25 +- src/heta/assistants/__init__.py | 105 ++++++++ .../templates/claude_skill/COMMANDS.md | 62 +++++ .../templates/claude_skill/SKILL.md | 53 ++++ src/heta/cli/__init__.py | 2 + src/heta/cli/init.py | 17 ++ src/heta/cli/skill.py | 52 ++++ tests/test_assistant_skills.py | 23 ++ 33 files changed, 1199 insertions(+), 312 deletions(-) create mode 100644 .github/workflows/pypi-publish.yml create mode 100644 docs/assets/little-heta-banner.png create mode 100644 docs/cli/ask.md create mode 100644 docs/cli/clean.md create mode 100644 docs/cli/init.md create mode 100644 docs/cli/insert-planning.md create mode 100644 docs/cli/insert.md create mode 100644 docs/cli/mem-clean.md create mode 100644 docs/cli/mem-show.md create mode 100644 docs/cli/query.md create mode 100644 docs/cli/recall.md create mode 100644 docs/cli/remember.md create mode 100644 docs/cli/skill.md create mode 100644 docs/cli/status.md create mode 100644 docs/cli/vector.md create mode 100644 docs/i18n/README.de.md create mode 100644 docs/i18n/README.es.md create mode 100644 docs/i18n/README.fr.md create mode 100644 docs/i18n/README.ja.md create mode 100644 docs/i18n/README.ko.md create mode 100644 docs/i18n/README.pt.md create mode 100644 docs/i18n/README.zh-CN.md create mode 100644 docs/i18n/README.zh-TW.md delete mode 100644 docs/technical-explanation/pdf_planning_agent_flow.md create mode 100644 src/heta/assistants/__init__.py create mode 100644 src/heta/assistants/templates/claude_skill/COMMANDS.md create mode 100644 src/heta/assistants/templates/claude_skill/SKILL.md create mode 100644 src/heta/cli/skill.py create mode 100644 tests/test_assistant_skills.py diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100644 index 0000000..cb82597 --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -0,0 +1,36 @@ +name: publish + +on: + release: + types: [published] + +permissions: + contents: read + id-token: write + +jobs: + pypi: + name: build and publish to PyPI + runs-on: ubuntu-latest + environment: pypi + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build tooling + run: python -m pip install --upgrade build twine + + - name: Build distribution + run: python -m build + + - name: Check distribution + run: python -m twine check dist/* + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/README.md b/README.md index 6b37f52..4d89a9e 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,41 @@ # Little Heta -Little Heta is a lightweight command line tool for personal knowledge, memory, -and document intelligence workflows. It converts local documents into a -Markdown wiki, keeps wiki page identity stable, and can maintain a SQLite -vector index for faster semantic retrieval. +

+ Little Heta banner +

+ +

+ English · + 简体中文 · + 繁體中文 · + 日本語 · + 한국어 · + Español · + Português · + Français · + Deutsch +

+ +

+ PyPI + Python versions + License: MIT + KnowledgeXLab +

+ +Little Heta is a local CLI knowledge infrastructure for personal documents, +agent memory, and document intelligence. It turns PDFs, Office files, images, +audio, code, HTML, Markdown, and notes into a stable Markdown wiki, adds +semantic vector retrieval, and lets agents reuse distilled knowledge through a +memory layer. -## Status - -This repository is an early `v0.1.0` implementation. The current focus is a -fast local workflow for initialization, document insertion, wiki maintenance, -and optional vector indexing. - -## Features +## Install -- Interactive first-time setup with `heta init` -- Provider configuration for Qwen, ChatGPT, or Gemini -- Optional MinerU integration for PDF and Office document parsing -- Markdown wiki generation under the Little Heta workspace -- Stable numeric wiki page ids in page filenames -- Optional SQLite + sqlite-vec wiki chunk index -- CLI status view with provider, MinerU, KB, wiki, and space usage summaries +Install from PyPI: -## Install +```bash +pip install little-heta +``` From a local checkout: @@ -29,82 +43,176 @@ From a local checkout: pip install -e . ``` -For development dependencies: +For development: ```bash pip install -e ".[dev]" ``` -## Quick Start +The package installs the `heta` command: -Initialize Little Heta: +```bash +heta --help +``` + +## Initialize + +Run the first-time setup: ```bash heta init ``` -The wizard writes configuration to: +You need to prepare: -```text -~/.heta/heta.yaml -``` +- An LLM API key for one provider: Qwen, ChatGPT, or Gemini. +- Optional MinerU access for PDF and Office parsing. Apply or learn more at + [MinerU](https://mineru.net/). -Check the current workspace and provider status: +`heta init` writes config and workspace data under: -```bash -heta status +```text +~/.heta/ ``` -Insert one file or a directory: +It also installs the Little Heta agent skill automatically into: -```bash -heta insert ./docs +```text +~/.codex/skills/heta +~/.claude/skills/heta ``` -Supported source types include Markdown/text, images, audio/video, HTML, -common code files, PDF, and Office documents (`.doc`, `.docx`, `.ppt`, `.pptx`, -`.xls`, `.xlsx`). PDF and Office parsing require MinerU. +## Use with Codex and Claude Code -Large PDFs are profiled and split before parsing by default. Little Heta gives a -lightweight PDF profile to a planning agent, validates the returned page ranges, -and falls back to fixed page windows when planning is unavailable. Disable this -behavior when you want to parse a PDF as one source file: +After `heta init`, Codex and Claude Code can discover the Little Heta skill +globally. The skill tells the agent when to use: ```bash -heta insert --no-pdf-planning ./large.pdf +heta ask "..." +heta query "..." +heta recall "..." +heta remember "..." ``` -Ask a read-only question against the wiki: +You can refresh or reinstall the skill at any time: ```bash -heta query "What is HetaGen?" +heta skill ``` -Clean wiki pages and the vector database while keeping raw files: +For other agent frameworks, copy these two files: -```bash -heta clean +```text +~/.heta/skills/heta/SKILL.md +~/.heta/skills/heta/COMMANDS.md ``` -Manage vector indexing: - -```bash -heta vector status -heta vector on -heta vector off -``` +## What You Get + +Most personal knowledge bases eventually become a `/raw` folder: papers, +slides, screenshots, audio clips, code files, notes, and half-finished drafts +all pile up together. A normal agent can read those files directly, but every +question pays the same cost again: open the index, guess which page matters, +read long pages, and spend tokens rediscovering context it already found before. + +Little Heta turns that pile into a persistent agent workspace: + +- **Wiki first**: raw files are compiled into stable Markdown pages with + numeric page ids, clean `[[Wiki Links]]`, and Git history. +- **Vector Wiki**: each page is chunked by Markdown structure, so `heta query` + can jump to the right section instead of relying only on sparse `index.md` + summaries. +- **Memory-first retrieval**: `heta ask` stores distilled KB insights after + expensive lookups, allowing later questions to reuse prior KB understanding + instead of repeating the same deep wiki traversal. +- **Synchronized memory + KB management**: memory stays tied to the evolving + wiki. When KB content changes, related memories can be invalidated to prevent + stale cached insights from drifting away from the source of truth. +- **Agent reuse**: larger teams and multi-agent workflows benefit because useful + KB discoveries can be reused across later questions, sessions, and agents. + +Retrieval quality depends heavily on corpus structure. In corpora where +important details are buried deep inside long wiki pages and poorly represented +by summaries, index-only wiki navigation can suffer severe retrieval collapse. +In our initial stress scenarios, Vector Wiki and memory-backed retrieval +improved answer accuracy by roughly **1.25x-5x+**, with some cases recovering +from **0% to 100%** accuracy. + +Memory-backed reuse used **82.1% fewer tokens** than index-only wiki query and +answered **1.14x faster** even in a small-file setting. This gap is expected to +grow in larger or messier workspaces, because index-only wiki navigation scales +with the number and length of pages an agent may need to inspect, while +memory-backed reuse resolves repeated questions from previously distilled +insights. The main extra cost is the first pass that creates the reusable +insight. + +## Core CLI + +The main commands are: + +- `heta init`: set up providers, workspace, and agent skills. +- `heta status`: show provider, MinerU, wiki, memory, and space status. +- `heta insert`: add files or folders to the knowledge base. +- `heta query`: ask a read-only question against inserted documents. +- `heta ask`: answer using memory and the document KB together. +- `heta remember`: save a fact, decision, or preference. +- `heta recall`: retrieve saved memory. +- `heta clean`: remove generated wiki pages and vector DB while keeping raw files. +- `heta vector`: turn document vector indexing on, off, or show status. +- `heta insert-planning`: turn smart insert planning on, off, or show status. +- `heta mem-show`: inspect stored KB memories. +- `heta mem-clean`: erase memory data. +- `heta skill`: install or refresh agent skills. + +Detailed command docs: + +- [init](docs/cli/init.md) +- [status](docs/cli/status.md) +- [insert](docs/cli/insert.md) +- [query](docs/cli/query.md) +- [ask](docs/cli/ask.md) +- [remember](docs/cli/remember.md) +- [recall](docs/cli/recall.md) +- [clean](docs/cli/clean.md) +- [vector](docs/cli/vector.md) +- [insert-planning](docs/cli/insert-planning.md) +- [mem-show](docs/cli/mem-show.md) +- [mem-clean](docs/cli/mem-clean.md) +- [skill](docs/cli/skill.md) + +## Supported Files + +Little Heta can insert: + +- Markdown and text: `.md`, `.markdown`, `.txt` +- PDF and Office: `.pdf`, `.doc`, `.docx`, `.ppt`, `.pptx`, `.xls`, `.xlsx` +- Images: `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.bmp` +- Audio and video transcripts: `.mp3`, `.wav`, `.m4a`, `.flac`, `.ogg`, `.mp4` +- Code and config files: `.py`, `.js`, `.ts`, `.tsx`, `.jsx`, `.java`, `.go`, + `.rs`, `.cpp`, `.c`, `.h`, `.hpp`, `.sh`, `.sql`, `.yaml`, `.yml`, `.json`, + `.toml` +- HTML: `.html`, `.htm` + +PDF and Office parsing require MinerU. Images and audio/video require a +multimodal or transcription-capable LLM provider. ## Workspace -Little Heta stores local runtime data under: +Runtime data lives under: ```text ~/.heta/ ``` -The workspace contains raw source files, generated wiki pages, worktrees, and -the local database used by the vector index. Runtime workspace data is not -intended to be committed to this repository. +Important paths: + +```text +~/.heta/heta.yaml config +~/.heta/workspace/kb/raw original source files +~/.heta/workspace/kb/wiki generated Markdown wiki +~/.heta/workspace/kb/db local vector database +~/.heta/skills/heta portable Little Heta agent skill +``` ## Development @@ -117,7 +225,8 @@ pytest Project layout: ```text -src/heta/ CLI, config, providers, and KB implementation +src/heta/ CLI, config, assistants, memory, and KB implementation +docs/ user and technical documentation tests/ unit tests pyproject.toml package metadata and dependencies ``` diff --git a/docs/assets/little-heta-banner.png b/docs/assets/little-heta-banner.png new file mode 100644 index 0000000000000000000000000000000000000000..9e36ab5f2c619c63021c7f93a0961d3a8b8dbe1f GIT binary patch literal 960883 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%0DT09Xix5}OS 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 z z7xKtg2{nR4Ord7@Fkcv4VONPy%~B(3Q}({vOD`spftdy}9<(ZR-~Wp7wz1~CrAZ!0 z3)ML2i9{`kMYNf;TVMs9R7-nfr&DI*x;lDx4F@B|tna3?y(a)BDN55;xa;YoNv4$M zdXNNs1FZ~EdNJ#A+1!KLM`!nSfY*MI9TL!grVKe+%8{OdjI`8hB;M%*F~U$5}=!k z`h}LA$=X1CIV^ZG7b;@oWRwi?+VBeLi`Ie37sJ(Spf<7& zG%+OT0fqkf0~`t1IzO4#c~HXp+OwieR5k@NwxdUDS9RJwvY4*c4rQiO-iJ<9#J$|w zWM$_Lck-Nd&&ZC&pmoEmn^&)MEPr54sTJw3h({Ann;?6Vtd(>OV z1u>4egetH>_tyOmHg<0kIE%D#HU)F;gWOKAqRISsb3TYAs_{oG+RL;S-lX;oG4N_4 zGo>UgC@{Pi9FVC%!QP3tuL7pu)FZs#DYf&w$yJ?RVC#C`clZY9mX@uB=e>n!A8n93 zf^S&5$koTxumgP~9uXf47i_7?ip?lAGxXA)n3!y>HF7lM^!6tGa0g6$zkmE5&Fko) zwcvBo0R~Mu-!2`)w@P-qb+k6>rbVCT8(=4Cbe3+j=Pyn+glxQxmrAXJI(aXMAA6;S z18eH66CDNQqx~KPH6Q9gg&GWxA%ZDpF@-b_fO~g37_(AB25(!<&yK&-#;Z;^ZX%$V z-N9UIy*a|2ZOnBf6ceR`lz5*YlYHCM9F)dEAc8>)k11U+Zwh&t0IxzqXoP1g-`$oU zYbc6X15<}hnJj2q&b7<>gXs}!!(9GyWf)V^}z+@n3yPZm%ov+8Uhb>BH0>5W!- zU`s)L!8fQHLK(Z_h^ZQLDYFa`xUFo$p;V^E~?)g2%X-|%9QX4n#t|y%AhuXR3zy-q+6#_o3kT`JhDDp zi;$)H5%hPB{&?vsKkuczM*PCaictAJWVO5gvg;8NQiqm@8ZghIKKYgx4v#_)A6+QBAB@qUAP*Gl2jA;^SiT2 zqUEzS;z__c_rT+wl5RqEL%#1(#h!@1^dHH9MMDB&?hRk0u;KF`9kAF;?Ui>FVfVgEP@&x!G#d{p#^Qbg-HahT_mZL2dGCn=_n0aLDR(H?J!p?E zOiyv+I7dAA+u%OZxR|$85_oJR1r3tW-r^!M8cVF6=s&@y6Jy13D$uDW+xcA~1Ux(= zs&%2rIj`^4)v5Qqn7Ca?i<(Rhbk>0ItY&$$#2BxD;YqQ@gh6LOo3&h!xB*|j`Y=2X z%06uZ7S7V!X78)=N>D7YMlKigz`!8zndBV- zRyXa=5BtpfKh6@{)4Yp{h|HfSm$Av6$h_I2wKiBqh0*O?`kbCObPTnuZ3jze91E5+ zqe4NDAJ8a&r$^dXINLci1-e;PeUbwMkINyD4;@XmD(mjY-Kznj8738F?%{2*%4>u# zhJa;&1u+s@DpjLAS}LIwszLlIs9<@n?^CKgE{0c7w+t*#f-tI^q`Y5K)!;wmwn@zf z@Z!nqoh`Cze?eHZ`qlCdPjKbFZf~L{8r|LQHK{pEG@=g&3tKe#MSU~v$n+IhbdD&_u8ZXglLkA|wNaq;q#cWfMKqKu=zP;h z$=^n>O;ZjXT#O^{%*` zJ?~vOCbe9SSbDuIal|eO!1GKsB=&P%8_eWM{f%!Qs%Le-6oniI)U}IrW$)q)&8wdC*a%U+_LxNyozKI0c~ZGmrIbM9TI`DYl`|xTiH}d8iPAN~$dmM4^lT+@nND zOY7b&wTUGfNp{4jnNr_p^XL{0RI?+`S_|GOmaZKFo@N@mQt(OW;;}>skCA0@O(*y> z!^~H4yfXk0*9~vbfDPE8w+c?d5|a@IN{xEYl^(c4k|nB4IMV$@UzOmT4Gg%5F0)Q^ zvBdutR&>r!pg3~RrjuC>g_@V4LPTAd9ujDuH;X$9Z?Z?fOCh;5tNaXl`7xTy>rgia z*@)*gipxq>lx{iCGHGOrKa^{#4Cl-dQMTXQe|HRO#_#k6z$dvx)`5W4(B($Jo#LdJ zd-TSE&8&I{Cj6#pltaf(8yt59hsq%l8te%ghlJy3FYHfZessT)ja~TPGSuP9I&`V2NeSNjPrF1M6T|W1vDH;_`*VS!w=X%3aZZVC(&e##??_QqNhhV@b;JPKRkR-G+4|4 zA!{QwG15hTtrKgNjNiV@wvWv%mFYR8uv-71Z*S6!1um(99~MYbgOZGZ6@CYASrduJ zS`yrhnH!WNXHom=*}Aui|4)_6PU$MNqe z7mmc+cXDsJe`SuRjEoef^+zYdSP+h9>YXkWhezDh_*B>_`a)O;NsM5S%<5|`9>V4%nxYu~ zK^Elb?mf$3dz&z8N5t260W5@pI6VN1kXLrDajZP8R>T9tUA=p*?Xsi_Ax$mg#l2~n zw!|~nXl6S)R(Vr{P}TBoZnx{UnVYqMomOl<$DrZN=m;Aypa!jp4qarN?_G6|IM7%l zE>aY6vkwPp;fR3B@^vu97Jjo@s{YDJqP^*RFSN4%dCtzMzvq>tsNT5+}HqRPyF`NT#6?kB@+= zBmMkb-oj%Czf?)GkQ4_-BrD?0wS29w_-x^{wTKmbtz~h(itrfPL*P(hxA@@1w#MdH z-f!K)ivh9yCpC;c>ZAK`B<9Xtuwu%NkSsS;q_-Qt*&xMNdu-v=ZBOCt>I)DwS~mEW zh;B{t_s!c{8E8|JWLQxT^pUMTEh_ zeGimGxdkS%WZ$b4%A?e3cu}H%3Lhl6&+;`Hz2mO&FVFOF^i86P4Y$m1cK2p?_>1C_ z-DZ(j0M4ozRQh5)Yk#NYv2nrJO3i$o-l#7#Bi@mKJq}NCKZ8&BIy6#Z!6h!XDN4;w z1#m)0Dp;?#koIMTzN98S^`ebk2T*FC$(T}wx=Mb)Eko^-O%AU>P(q==#xcE3p8fLs z83soQqn8o8DXCh-vjTdE*oYHqaiEx$LoG--fId{+UQZ6e*bIo$p-^OahlK?Q#kplt8=b}aQWnIl;@WHn>g#Pgd zYDaIFWr=L3&}CAS0Z|)}s~36m(hdAqrp8Cp3mo>HHbY5a+FNTs_M0_Amu;PWBKhaj zXw+AIiStB+t{Qwnar#Mn{j z3_dRtt~7ZZ8XFg~&h7(PZ<4fG!9nFVRj-a%ScGq=F`v>Uy%GylDc3Ydr#Yk17T@GK zq+ScB=QgZ7rAOz9CH?e!LwA?Et2@J)OrpvQy$ApMGb?E$@MAik*g~}_hQsCMCiP`I z&F@m2_mqx~O~C96deeXHk~Ex`jl!^raH4sGCzjXNj^^&FvD)r_YwpZawLZ_iRXR-p9g_|$Rijw<4ScO-e8!wQZc+tdYF1T}U&1|;=Ts4BD9yd%Bp)Gs{ z8C7%PP+LlyfcY4q(DD}^`(CP0IWC)377*B^q;{hZJ`C_Ghe+8Jt+eg->-6Rm`&FXi zF|biA$OB-eN5w6FfGxp?;=YCi&ir3cw_VKSaRkAtS7>NIxG3%}KN_c0&IpLeaI695 zGIJM>$tNIh7Q3>q#72!(sVy->0T=jaC1GUAM$7Q$@_43^Kya6du-qJtWwu*qwE}^4 zZ!rc1BcPd7dN+!wi?u>{!hlGxT)bF1=g@*8)~t#3sX`s5EHx(ylJMZ;7fVgpd%wXQ zfogJVCJ#RFdX47x&+q~6SY9ol$fITT-(a@q>trxSH(3z{bj?aLbhp`@2 z9u?!V?YpBJNGTiLBeHPIM^u>ZVS)32b`Ly3o3IlugP-!=LvV&mR^oUo07&;*i8&ac zcVZ2@GQym4&v2_V6K%(anuW?->)tRDE1kt5cKA0gSp!-8Tfm~V^+XIY81g$h{L$Gtr680Cnr+rmRO+f=;Vt(4R zfi}1K9H1>+hf1Dt53|!jkr?8~RDR3ltH87+_J#$A(&6@mFXTL1e-RUDS&#VV6otrm zXW!gPHBX}NA)F{R-eFhBvhTINZI<6tAbd1m`lyTd-#lUPikdTTlg}5H6YU+# zTmL0yGbeb#^*4Gi)TZ_%G?G7xe|{BLrkA4lqp3J&Z6j2VtVb2QtBlVAZ^{)9{T_Pq zM3No%o0!HTck3nJBw1Km4X|-z#Be!XstkV{<$9lXg?F-RWtuzQZXWxfk=eo>o2}iD zzD{0DhTpmWv&enoTq=%b$Thcd*6C{)J7~h{^a+DecRoPkb!tH8p;tR;4##eISHg8% zUorH@<7DG!v+7WHfveL~Olqn@ft}O!Jn{Up?Ein0kDjf^c=d!VOsNmn0JWYrEoanM zO&I^gP->9=Z9nMv=O1V|(U;m1|F!gyzu&yMY`+uwHYMJs2@9I3@$++0&){fwtKXfW zCc=g7D`E}F>YE@LIrhU2XgR*~2|^9lF)}|V4826xC8%mD%-trxQAq5> z^U%x?=U{*z`O;avUFVdZd=3puKy2FZqKf`|8wbGPu^*Ip?@9vn)+E-c6 z;Xj9tBDc*ax9f0Lw~FE^6QQ+#gmB;h0RR9=L_t&~Tj+WiYP?k{$~ZB42wr7G?DJQZ z3yyM$1)Lg8^=1#>hEof?{w8vzxHhZ&gqmCn2f0C^Tr$X2e|2H|>iBRW1lZcvg$`cj~(Iq55>DGxCmO`LS9fJcAT8z4EfHO`8uu$emfs=`0CJpVSgXp*!ipa0?$WI$OKsYi$-)w-thpTL28o zWQ2B?+u>K(fr_MTSc!BmdD$@99DpP#R**HST14pUiC*}@Oj?Nth;*H$Y zjCsPU(ERBdPWio|LptN>f`C@GG8e!DSW zu%ck}B-)rA!o(JL7M#*5&V0N@Z9b)k0iR~tOz}wB7Y){a&gDa{8i$gY^a$D^eMI+z zu{ox0u6wVAEM0$zH^A|Bteyi(TpC{M9^00^_X-2dKUZAmB5 zrY&^7CN+miCX=A)tqA3KFX}((wn|4DXd?=xA^U9-I{`2%I8<}kjp)nQyl(#Wb? z)p2pzTq3~|Z_9W}n>9<#em5HRClKjC>{Iejf->F{?b1|ajP{x> zANu*G6Fvqol+z%$ylpqjaoMn=btMe;MZi32{cQBJrF|WU<)uMlA&|)R$8!NSBGF_2 zJ_ubXwxvn|FlS0w>v}_FjMX!E#)^Dz_RRu9_4D9{BS}*T&gA8bHr^GZ{!MJ>a$_#| zMcb%;8{0G^%@8<6Bg=HHaSL2^wj0n!Wu= zNSp3F1IRnQdS&X$y)^U(HaS@B@roHwDpuqpV}^y1CACO@?UX5N=CvdHvsaI4Goam( zoRk>KOx_QRgk1leYNl!{zMI0sJzLjVXE&F21wURB5ZlzuKTNJ7hN}DPdwp~8hS|2D z(g-$oaf4^8per{-#eVm-7Qa{>eE`u`SO-pNHr($em$IE?Bv1ar?k5RY;uR(CK6IKX zSbpxvK@Bixk z%GhldhP7bKmak=eP$;zKy?c8#JdI4l7a0m*ggcqpQ9BfyrGjjibXUZ+R-9E4=zsl5 z<#|Syn?}%IY#gM8YvH%Q^TKezfsN{r31L#N4qwG}@mB0nX;~LGp(Gvc0i0aHboSix zJ=ZW63-TLtm~Fc%wkbELC>jg9P4Yopp9IVielR2ZfW+R+>-visE`pKI255O!)9 zQm)=C^t%&e^kNCR<1Kp!gV6Lu3+J0iAmx-FL@xvh5w*x=-N)GTVS-Km*vTt>m6qJ= z_L_z@R6&^S_835?MKQl;;+?mh+g+xtRh*58)uy%dmbsC+$Ss!O+MrJ~@GLDlu=u_Y zV|8*v`R%&Wf*xlfyEy)Z&tcNbN)yn1oCPwpMNLf#5>`fBt@1)uB1^nU3<1*Z7kXza z1Vo2_%l#5(Ay>f89cCrk*`yebZ?NH#%|sptYKalp!P&K$Yka+f+{W3;{W@(iCw1=r zHJBVh@?~*hp$glpyzzZL>&^4DM+MhH#T{ay2C#_DV0EMw%CrT~@exBWPG=#$1maBz z>u!`h2y7Xn1h*zU7=Dhtkpj=%j_tbybD_`;4IPQ(DHbhjJqOl?S7@OyW6IAwz_%ptD_ zoH?Q(H!<9}*dF`UoNq9-6dtB-@XfLchmpi#?7Go>ly)d=o84LA$I$ih#K>N23xP5R zr_~nU&rBcutKVM+I=E)dc5}YemMh++YDvd+D8ICd^eP`%-CZ6-gcYXL0EO<^LbY(r zVwMO6!sTKzrQO`8uasTS|g8GG)KQan92de?PUI1e{;%eP7; zy=jD7Y&$1Z0tg2Vnk(*XLXSdmflzf+cXqL1LxL|p>lDAp@#b=GZIlDUgjh$)i`5#W zZX_=dGbu{n3GERFMBmKH7#yixcLQOgU$s|#G_pzE55*5Eu)n(rf#oPrOs0v+p7MO539?1X1}D3Ggtt$tMVvq{?D`Xbx7n3+38%(VvZ!1R~qG z#;+pWF@zwN^TTsq$BZJgUD_Yjw5bSY?4u}+2Ws+5^^1NQY^U^w8zl>C!(VKvIMBZb?Y4dCLSmK?HOEJb(pGdFjgmlHYX60OlK}W4vSFkU4}F#vi2*ByDg!1W z@a z;3QpXHkR-RVZ@1 z)h0D-%24BAfK`zyqy2y}*#YQQ>f6@RrQxeHui81j8wOOQdOEd7xrehYltu-dJ0!h3a0FTcd*ESy6#(b9HeybiolKT2uZN_nLRO zidbRXb;DQp6lSoZ5vsmOo2Vy&TVM{1)~*R*AU^JJ8PtJ>*Vnzo%%M?O#rHAEWbm#s zK7cK^W%i%f|H+4{6y$w{qQt18`Ym3DT1Os$wF}ADAMa*1T9y0;6?O;1FzIN!2AKaQQ-;?gi}_+ zCwdQ%IG(zqls7jsj82D zri$=tD6m%qKH?0>*;YEO zG&MN!L-Z<-S*_O#shJL-B76|6(#?6oDAvQqgyuT}83KXGU}9zO{#`L;UdxMtwG~bl zXPg2BqF9iZCx*fypra$z2Pf&{nhWd&_16O-zL&v?uu~EId&GgR!j@!d6*EGuw3!I! z-2sU8Oz8sORJi18S{3gv^{eUK+Tj%rY&27&DA?*fxel;xri+CLv{Pl)?J7pVmazo~ zQ9VAcmmk>$))4$_P*}o4YP18L8xgpPo4xOH&ZQ*awO3}md7Gzr$sZWDP;S$TdUwMN z=be&a+$Hz`BCi1f(pf`MfVd=e=yHvv5e0WFlVFEivaB(xD z3}V|)GtrqwPA@WL`wWRD^oSOqO9q{%Yy z5v97BC)$k}fqsTabS5j-R<3|_ue`?L#_N^-g3Jfw^|$0l&>mp1hd`CoCeYGGfl-A) z;=L{EF?cc5lMst|6!RIe0bZP)i8A@yZ@uuyojw=24I=T@dsTu(i~6Jx*FRGueMlD- zt{Ny*Yuy>pQ4OT3@8hlU#SfyEthw{UtK}|c=H~b=VvYmdo#s7;E?Y#?U3U*dbVgYR zCO8)G{8kT5f*+KJ;OiE*!8C|yb{{^hsj3C(^mV3_*V@A;y@Y|G6hNAKhRV2Y6*m6% zeH2mcLe?B9>!Wb}X-*cQx51&~@<~%;#5C7h%iP8oI0Ja76TKy?mrG!at9r*d;Le&~Ys_?0$}L~Npvds(ZzO*c#ciHy zbiu8vCGoQOL*A~7TMj3U@Ip{#&~^%6+pvU2E5|z+eolc#Pvt?DrOEq4GZA{n6YP#> z>A7Ew9J#EzO1Y*P;--nB`w$0(+qYhNj&Scw@nMg}xjWoe{*+?eHVik~#i{l+_c%pR z1l3q;=QBUU6~_J>po80vc3x#2aHb-`jdJ8IX)9%RM3aAKzj1EFjw*+e!hE zvTAylPCpxFk87~S-L2|fLefMw<$%rwIJ|(ed$qH1Ev=*@q`}YqIZIQuR;d$q+dwxY zz0NAsihrkru%S6;hY6*u-In@P`bVo)0g3&*=`-%gO)-$yhw9OWPd-E9Hg_CY03hG+f@<>y_nH@*xF# z!yC3Dg4a!7VOX!;L>G2KNbRDiZ=kIQ@`R>%GyGfkfve)wx+IT1q#VD7SOaqa(DV%^ zitUoLR@Ac$G2eLnmnlVd{XqMJiDTKIn3qSYRgV5z6=4_=R>owmVYZTlk9R}Wz$;wx z_BSPPteAc%ej5IKcJ8P2%>2GinZ+t!?NvfxIp3u5P%fL^YYk4*&J(fHQh^btP!pAU zjs{E9+TV9+Cri=4jXDu7dasfeRL}lUOg|-&?|1LtyN4pQgw<|}rZ`DzYvo&{Eakfz zF<3WxatXp3)_oAv!~p#!PO6;jLUnW5x~Iy0t-jMf=8KIxjl4qhqX{W?U|_KNdzQUx zhf+j5;*=2?GEFj|kdwHC<-+-Vt-I~DWfkJ6EH^Z@8IrFxpZTw`!|0%=k0f5?=L*!( zF4e)q>q@KNKrxp0UfQKXc3}eQ)l?$MSzO?n%4^Jg_V`BMzx}WOWrNXF@t7pEQLDN1 zd%R0dVwrMSYz)1F6N0gi9^$PuaBZ;3c#`No0hvj|?Z$)pc3m|!Ce09(pds}sN?H)$ zgAI|oc4SP{5NcCp7Ld5Km#kb`HN?f3l~$;NZ(7%kVH0<%!97_Rrj`{8IfYy0)K;^YOSV^Gcvhbjb7blOj>TQ z2mOaQVhpcgc%4lFzg~QU>miCfVAD}MF=53bl&^cdkao9f!;ue(d))jzLNVe~k4U6y zdw#Ns>p8D~Xx!pE5P%t{H5xT3WY$-U7`T^VbY&U+L*NQ?6;QtCqu$>W z@{qX={>0e=$ev`Nqm@c%I2+AeDiAfAjZH3l7YO>S{&lZ^p?R;~o0sUy_6)o0awjh5k=SNUhe88+cL15FmXH~?ne^p57aAq^Ocj=&aR_*6+T z-MO{PLXSy#eWVK1-L7pc* zo^zjx9{S?km&bwd%{7y?q47I1I`nVSLa#QS4>vy@kb8+hibsJ z+gR;5p#TmV%sAtq{i2nFQsl%$RN$jXxU;QQZAYU@E8plmj^25Mr6As#_O1s1+RFL` zNx_D_QlYt8OrFCDjdhCCSSC9|w1z zCc!jO6L*lG7i#a!(fg`M@J#}D4KD{jio_rU^CbE+YaKY>=0Fn_C9wmv%>(5+f&5a3 zt=rvSrqe*%BI=U8pgD{!4!$~f^vy`#2Xj0fW$=kkoW||U^`~kh;UmR?%-&QPnn7ca$svy>S`&2JX}lXcGZ|kTg0tB(!QQ*JgdZ4_-%V`Zi_s=| z$>#$62HfrMS`8Xq{YVhOzC4OYm4^}@ysRbOT3;@#!U;jVeB!;czcZPc2iKZ(+dy2# z=`_N(`L>RL=z14tpDt43JROdKc)ckt@1IS`SIc__F0nPMCkm3pXv4iJmiB&1uSW6ZpRtm_vI9-tE)*7PTOZh1!IsW8fIGda@?=_Gy)BB+pwdOzqU> z#N#8V-^GR&t=jyL8P>|kAVoBw@7q2LZ|9=IsPYn~u#t-Y((SdO8Xe(hI5X0Vw*Rbm zLTVs-=;ns!7dl)an$Mr$++}x4lN!VEPge!J6J*6J|8cV4b#tk-jeFpB3@pIi#*YoJ zHK^y=)<$AhK}c3T;M|C2DA-U;o&pfne&SF)YRsKH3-(~wc%zGHo~?56kfWD#`0U!q zUMpb4`VGU)3hYL`*1YHbm zX+)~rb{2-DHw!u3#n=|U8QlXyPEw^c8$9JANj;G18=eeDbn2U66F!x~$3$5e@tGYz zXK!rCT7e<40X=V(Z{On-msX{&#LV#+6^0P2v4K~^2;Uwg)5SJbX(Mx zAq>pD3A*n_ZuJ_K7y_Ss)Is-MP13 z0pUWGQ+|=|ckx9z+kpCm$To_7awBi@DIk2q$Cd_4BDGeZba`UpU4x)$zQ!0tB7Gc< zC_n`+cd-@Tr6aLudT{T3myS6%Wj76%K4d)DS>079d#CcGtiJX$5-KcU3BL`y?6D}i zth%8e(5EuvmG>KE=7OghTc0E`9O$h#Fu2u3^sG0!YA^+&RJT8X%5$J35_M68wMNqb zIY7q0b0`IahogQ18XDK(tn_F0l8Y=!vKqs59eez%HOU%!jkP@9^XlLmbm|^7?urfn zX9Ut;siP!ui#{qES!xDg5+Y-oVdhkV@vGoI`Z(dVa_>2R5{%xPizRw=xKFXXwR4i$ zK@@`0(_1mo7auHbJm{A0Z8kU1Jiah4su{yM+}to8!#7X5SPK^cB%vBeU!1cx(OqIc zQ5vJ!pAtb(7Wo+%f#4;BH!TS06G{`-*G4D|aH6#c zFl3`h;ZmJ3?ETb?sz~4SlM#{wn4xowWM^52u!#{;pM%0Moe)l`a9*U|Re4~Xgo3mGdXIkOjs`vNjm-ky`_b#x z<)%yGSt|`StHFq@d)#Xw>$->i&Pt84yCfcTd|wUcVR2vLS=0t;ocuaAy6%O%w;M!i z90-}IrS0Q{GMaW}TfRAr-Z0)_vvd_Jy3{st>(5HM+Z!d3eh0s^lzs44kAczFf+xL; zso9aFVQ#~cj!?ri=o^-Vz@6Rq%~%8b$Q%G<3?+7-hskvk$A)B_i~Unj|! zRjP&Vs=E_puj@`L>nukS7mu)n``N^{3mXo>^4D50b1>o^;7uABrB-6dS6b1ovT!+w zhZ8CeaC&a%E}5a(<&4yh6g=dvkuIgSO!XD_)-<-o{>E!g33<|&6CxYzG_MFR`R*X_X^k+{&lc1`Wl3c^;whSY*Hi+%G z2OlKn%}1UZHCymSrGiDLLMxr9Vk?XSc2a?V5=hm3Z(i(tYD3Q)ekqxqxVVV+$!sR{ zhE#-fJ~&gYXF<%pNKEz3^7+bTGQcXCltsT)P;>BFy$eM|>h)al`T{bx?bSyGHzNm; zO_R)0{uKyM2E~+sU};OFyCGgql4s`n>bdB7C!DnN&a_Ru>z9ZOSmntbFl7Odq9^~YNH(vAd>s7BC>>(( zyQt}DGu`Taq)a>JH3PhbpLC+CVpNUfZ+Gp$(kcI^7-#o5c*QMZ^hE=orJY~BbB&aL z^-Dz$C!8;9OYRp?>zvaMOFJrXW6K4=AoJe9NH(il{xD9mj3cMoy_WxzCXDXE-BFPT zr^?^GwqBY5-s!q_TY@|{fdQKkv@1G-xe&&9yUz@Zy&7`{R;HB5ZqduoL(T9f>Eyv$ z$hU@AsxobQD7~7_NYd9hSa-XJ0Ty$`& zfG*=LQ@zs`Usl?Twt6U=*gVZ*>MJ^}3hEjqqfaRSEsK zT^7>`w#@IDk~{XeR?5`#Q7|waQ|TQmrpA*7W;mK!!ETP{@}72v756af@>;w5ie?M? zlSmpdJ_rxx+#ULWIJ8a8Nq0$YW_u7_p4`;lH_5X+^IWt0J+dK_wGiWJlmAB^ZWpNt zQwH7MZ~NPucqca-fcg;lUESW`5;?YAqsWwkrtoF-iA<;pe&q~fq(Wk?N@kL7379wh zOB;SUlBjYQEWmXBbwV$$tjciTxu<<$7r0U^6?!-zPJX2h>2+KHq{Jm@P35@ThIOy( z>O=B*y}J;w_z$%RMw7=H zxPo+e+Z4X45x#S8^)xDe^MbwE1RH4{D-@ADIE?O;psV}Rhn@k%=!VZxX^fl7-}&yl zn-*gbjdjAfC!moVc!V$z3u-4?d3C15IQm2Hl!m5laQ+ae>_!U9cWs&#U=dOvdgz)h z#%(AgD5ggBi^PF_=t4c1p$Pnnd2+SVuox>(-TlF%n>%@D*!wcH`YknY_zos<)`Tq5 zX38kn+HyQtzQgP9dRs~%Q+@niFsY7x9_u>?k=41a!5OA0*U+^x?dM&`(|`f)4nc?T z>T7h5@v3G7HUl(b0LVhE_Out3R=GLN7Y5ej?=OeF}Ji=Wh=VaP5JpwE(`h+m5pfV|$4R+wwoz>qijP{nb4KmOJ zUCIJ23w*yCF$4i15$fs-&B?09vR{iNfv_Znh@SB(fpo#N?ps4U5t?d}Zp1=tZ6Oc} z+pmff`;VTXkh@Xn!OI8@hCedQoy~0)rgdN5)t65H9h*&_URg)(4irp?go@Q@zE~f( zv{v`@Ub+CkI7++6XPN#;8(BYFY-Dcd?%kZ%|2oZea2L&YhJ*2nreD)46#X= z=HX!BsIAH%5NpMTRu-K;h$!?jdo)Bcg*J#mgc@wxdkvG{E9FFA`&d^V22ukGCp}7BH~gs=FrAYXn;te*o!;uYOTK4^Bz>r3 z`LXVwdb7x~YYre6f-mu8yYsXQ{P-Fg@fb-o&osD9IJPhr2y;5DUQz6(%{vgdr4@)? za`q@SRu!~Uy8D7y+R#&Pn(Q{hv~eD%Y|cA+p(~o*xd9om=E;7tsA==52x8zoI1>sx zx<6Fd3pm&rbS^ac_O){2umhS`lzhhmZ9TfNL3ipDy$F$kXd%oDrh-U+?z9+8>>o;@ z@zd-Ci!o22x7b$r!P2T}vP+(fNjNjutTOM^Y7cGWroq3xu#dd<@tEdZ>s6{c?jVLo z80}iSDV{4UMtsfZqxVUuJ~4;CAm+L`B89JFEHtS<2aXRO5I|;ZB&NFRD-CxEFe)3* z7q?l0Y7=xSPccK>&6<_RS8>q;zdKMTJUB|Z2Rt@@c(i?IRXrBI#dK9YdNzl)*7)i~ zC!pDiid4$5^-jMhNVs)eZOg`DQA!(w6W_!YL zO9cRVzhO6ZNCMkO2_8L2;X2^xi`_XZ6<##I$tgzUe_`~|L(eAdIhv?ji z)}#Kf$0%l?LTO^tpvnEgHg^m=SIj1c!zUwq;}fGADJiy#Y@ea`{90g#4RIMR5fps) z?sT;B(k1Mq@>W`}cM+!}Zxb^^BYj+ZT*s4R^>*9(0Rg6`HTbv8=tcKWYb(|&=?Fsm zS-A`%RPKQ3Z39687q6999(X<9JX`RIpwJ%p%!UxA4|8DVss*lqBs512J$l_zNlNUc zcD_Uff!w3PmxlAGUC%5%+PihBK1C!Q^KUoUKSRBsigXz(nbWvpq!e4qcUhJ?LF_ zsJ4oZTY|L;@ZxC?TM^n;j-DEmUQ!&fX!gp+S zK8{w_QJ%A;+!WqOGFmJLhtV6_Vd&eLr?G=$8Y<^Xgp`UaqJ#%t#vOzpcvd=4k|Wu* zkP-6WUrykr%}Y|MS2NBSBG1Qib*;jpkc3`Q9evFinJ1TwOdbjfBwrD7ws*<)E;KwO z-*>Q=tY~oUlx~=%l}5*UICn5ATYc*LKyP8VF4d;r6vBCYNNO!S$oEW5wclWO(|fUh z>rEIi1h!*ilZ5|d>sDA+-GJ(?Y^qLQM1NNlPeO{N4T3pl4U^ybuBrhqV3f0Jpn|Mm zc-5HdKPT^pm|@cS9V?$|FV)URhNe}e+!lV^_R94p=ToZi?}hC~g{dnL6TgNF|qtpgc;A3KHGwQUy5R&sxus4b};IPss+j z8tiYP)m?DQl^^cp{S3Oe;I*1ntj_DAIPJxtm9XCa_L8Y$WohANY*Z=uLVZTkXE@u* zFqKgB3fqD?d!ErprzX@~h6Gc*-PH|#(H}P+rB)`|T=zuEbNcdWzWI?SeX;s;vv zXU4^8;AFsY6_`qFv(zbpmCQr9GhnDgztPHXcT;mo6UW(z7oJ*h-|5SNEqMte&(O+N zhe9J7w}aBW z7NB=*`(L?NlO7?Cieom4fo^Zneu{KQC$st~;$akkV5AETS8?AeyQ&4lFc<^16Dw01 zGy~K3nv-a0>-wwZt31^w)$aR%aiJ*2B*KrI3L>@Bia**M^exu~2OA59MgX;n$?UO& z2I)?vl;P$;C^Z={Ohb)9-?{ZpM0f#Rbo9?fSWmmPFQw|eB9<5zdnzZJ?;a@7#oPqd z-m8L7umpfD>kO!Mh~33FRoa(V_9#F@C6;$IINn$^kX4OcMOv&GS00s()r4>Y9KOKP zY1BCgzM6s|mnslqGna_uHPqA-p%^Na==xgd@GZe)YHn#f49Yf!v7p!UI}eLTPZ??Y z3xOM5{$fI^#+R2C3C2McQ(vy~sR@P`lu}Z_6B#0O{yg&><4`}rUc=niH3j9^kRx0` z+s(vwrq`^;^l%lMQrWyyO`5#Ud62^}AUNs6OUSP{i=&aW&zwW#Jqshkx@Vl#&S&Wz zp33S_=QvNqu7lFfeV!+6@P750K(H+?D9y7wbjwp>%0^HTFO_4TlaCa_->nEZ?{LI} z2YMB|*ej(a+8W9e3=9eNud;IPQ=(xsIV#cVvkUJecF>Uy=z-9&iQq$cj#pelO#s5R z`1-93CH1xIv9qOZG^_*Fp;(9bjs_5!!Jh( zg??y-$4QrJ&VG$qV_4Px8|HpyR23Fsk8pQk)fh7eDXt=CJocYHeia4~%BGCZ7;Z!C z$C0=o_&&-cCGBsLrjh>j(w4Y!0S-7sZ2UO*w} z1Ty^?)$wHmBaGe4=^T{t7%v^aWpGi6z~k*U@-}&CfG$s$C<)cwFTe%7j1E-sq&5sakJHV03Q^?yj#Jn-J1oAKn}p8;&J&L z9qp2GC=T`|vL0g7H9o!K_|6}TT9AFloP*JIkUXG=AkvN=LtTfHCQ8ATFm+7Of*75> zR=%H_w&82l>t50lv_xkljjOhCu>K&T+&&9$v=~81RVjjJAr#dh1Jo1{Ko@M$*zDto zO^#YytxEc2&6ju?+M!NIQt%B4Sjm#DgHd}|$Bi5oe^BAt$LpekuLp8#9Tl}s^il%z zSV{vgT`BVKU>mpjy>0|bDPUgQL@itbV_BC=NG+yw#P}0DwpN|5?UTp;)rjCjEEhhY z3NUWbP3$wK5aHp!sj$jjx(exSme_W8l} zIIbSceUwHmYaWN}Rox8(=rxnPXynhD{Mms$>7G;}Jnmz?5S3=* zSvy+|(32rWX_^>;+i>Ge^(NcCoNyQ%h%C8IxGI-)J7 zf~flF1zV^v$Loi>_s;Pb3Zsir^&ZgXuMw}$W9F>o`UmziDLjOBsFb z>+rK2T-iHQ+YtxHbnf1>t)2t0$5gS%CS+zo;mO(~p#Fr5XMBrdaY~cY&$N9Ud`a{e z(7!v$rMca6Bim&4=rqNX&+@cz)4 zk7iA*+#N+!>swCtJk#6iDE_Ifu|d4_G+p!f)t=9rz}n7;HtnL0h%azUB5FHnLF3KirLWdVSpCQvpwn^4_y&|jZ zR@pXukXc5x>kSsyxYk-x4zz_UO~W4NtB6{v(}xjQ;Me%^G0^-%1o4WpzDzPJJ09Pb zc2C~|^Q%HTmBf$(dg*xev{7YTn`+Zf5u2{xdldw+@}eVdldm6aJQ33|T4oClOYa&A z0}=RMrre5^wC(? zRB9Bn8tz1O$ zMaT}ki%u%hT5Eaki}9hQMjvE=<1&ZN$^9RYYwZpeof{fpJt{L^3yXS4G<;j<=IUYP zPEH0JSWbdKYve-IcDe=W4}7v{SQ03Fah+1zhmfCSe-;aggN4X^5pE>2x=p zg*nzEP{HBtr*(#wf<&1zHKsk68owyZ?hh1s3-W>P*PX&ckcszJ0eUsaM^1U_m0k9( zgtoSx6ss%dbva&tj9LB2=G|`7gFkKH2HG)~+sQh-?^rufc@H1n>1p}#qotj0Y^wUEN z)=o^}HB|+Kc%%EpusT<3QtRFU0)$@PRr%fnjF1Wzw;FYf1~)nE2s{r==Q7m+)i}c9 z7U9ZgJN #b|oC!g9iEhyR`7<;vr35(mIAy`T1!vyJRSe>jKV>z5h67L=TNWBVX6TGTws!!JMo#_@d-QhTI>Iw@;$A9@st~k1NW|a4NMe*k188A3{ zV;3UCwybB}rl2zfb~y#;C4k@!nTt{D*o1d?;P*$dwxM$-n%#WSR;%3Wy=A%Bn#w6` zNc~*UX;jF;(`>Uh%C6zOY+?{_b@N2P@0}_!(1+OK6-iS+Wgl>KMzJ?u{v*Dq)N8Ku-Q%@jrb$1Y2*ex&6)1BuX+3=iWBmf0sC zOmk8bW4T_0r)4)8PW_i&&Z1EX6g`uwPrnI|M zp|Kn$?%hH<9Tw2>y4vlZ*cu$>S6(o1x%2Np1h%oG?q4^^6@jh`rA`mPpU>u9<85fB z6b|$oLLJvysKDjf>t;u-^AZI{t$|YYG*i>THhK!LIGx>pOQn`0JVCxG zG8=K0P%j#Ut$R~iDrF;XmMgW+>Q!Tg7tXp!#1mS;Q19OZ5~!EHMz_12_ZZguQ_Wh4 zeC7Hr`EJQ*)NVFF3r^-$q6=Sv{TB05^9>s|0gu-XMloOnh2nR$VFnzAsHm9xc0kg4}&N$sk&d9)9CS1Y1#rvy!Sj;e#hDxj!Im@fHI|c?PEWHcuK^ZemmP!bcd}gMe5l~&e9K-viOA!}>jo%OlWoI; z_&>Rlsy15Spm*?r-W471f#R+qpP|Fr%X!iLGFwr;tjDaL5GRrDYKRXGX*!HM`pB^c zT&+|`J@5iy95V~IG1hx@wC?)j{U$n}Zf_As_7!t;a8j28^q}`qcIcf7FCs!5 z(1IRMD$mto(cCNuexnm2ZG<^PqzS0aJDx#yy8ko7Q^QWfEAD?&4z{GAT!OHMa}dbPdx>dPl^iP$SzsPBOQw*Uho01;n5n(>WeyBMup z!gg-38r#S~A{f;5R?O4GT@o{2@;fq$8pUt7@2zLB?0bb7`J-0L&x!T1oGJo-ny4ZG z-Vby2lgbE?PxsHq4!~cG>qsy;;z-t?mxWQVQ08wg*n{RG%1N6%`(!Dx`m07$4a?wt zivyU^LmVJV-nT->exh;I#nI0qR}VyudU2}^vo5UV4RvCwbwkdZT=|{`>Ic}}r;lR1 ziX4u+u9`QkC!Q@6`vHDXx923Qc6jL+u&FCd#qeD~XaS(|_%PwiB!TAD;7;!M)nH_h zO~qe~g>Jn@Bo*~0v3#%bC*t(rQjP2;-T&ZXoj1MPwZC$c+j32pP?;=S3p zIPl-h3cItd@{qE}4!>>@oIOR0pBN-y$ciWv@Qpb|8Lf;d|` zauDiNgr=4vSII$-&+LAx@n4&!rKh+8UNQ)kaIH=8CxhH^XHJKuMc2Tf6&_#3y-YJe z#OE(DF1V@O#&`nRd2<1B3(D%Xr_J1_)P!xSQ6(`Cv?@(mF?bDPy2O;A5-bWVebHqX zUj3Bsj_(5F(Y1(x*upOimY#8O?T6@!+t2%F`t@kk+8PbMm=<MN^R2dC=*eg8tyL=&pe0M}+3fHL zYV#YFu(s#LI$L67MSCg=n~Hmj>xHc4e&1Iq#l|NEjePY&;@Dz0v<3bS!yd}trWWT- zf9{9O0(QOCrqMsfCt3XLt7P3{D!3`ItA!%v#|Ks5mE&w-SWiJAak`HQEgr1ENv-Cm zt~uVa^%!q}{}~u%vlyhar+@*5ua!6KP7zvp|GDMM}DT!N5@YblrzGUEyoC5)Mf1G|N8QBB2xM1#INv zL+n=b6ID`R{#A_|8X*k*XvNAK-JBU@?ayo20NORU%MjCS4tmvuTP?T5a-W0JQ+4My z`&!VkJXWoHtZVtjT$dur>2rsxTXW($^8S1I=&@>HemI?pYix<1sQ@ZKic(`KA9>4b&pQ;#ASNBPax^;af9{+sfGw0x9fZ|?Xg>`lanNB_ zIaKf*(g84lGmYNQCpvun+c$t>mtNjCgQ8i?_P`WIg~pV`{mO8_r;FiR;jYwGA%V}s}!cVG=rZB9HLBeuXV zgygg2%BIk|)R`j*4)C;=YqEeO4@&YDud+M(xIYH4n(l?Jd z+$4$=`Ar=hS&me$0Y_?Cr{>wpN~y=_%W5zj{{rIm&xjxc>4GJKlv3f$Ote@m`3M~r zumOyca`xoh6yuFqp)(|&p0Z&ol!u;R4rddYFRLAHF$B%ts!0 zOGMQj*KO<1EcQG`BNlk5b{nj48^}xAzC3YOQiU0PdsNS(9{N5DnaKjqC5(c+C7w28 ztT1$FjsXZ%j-3bOsGe_=y{04G_Q97Q)47zGp+2+p0c|Xn9@n)L)YdiK+74M+H?}-p z@L?@as(=eolZvVZQ(KXYKt^P}IPc*T*WmQf-4KEJalE}_VAr&TDV`nKjHNntav{f; zNEVM55GFUy_u4Yw3sO)A^fZCi&~$uRFr|`_O(K(HAa#W`F9!EeKspLXhwYD@NeEMWZEVJm7W)s88FQq2&%dznQdl=HGl$ zldI?c`stO^Jo5DurgQ8+2{J#UygKm*FN+BKNO|`Y$-PdJu~THO)4|#{IccTGAU3yD zJd%|8E-yC)}xGAiQgP$OS4^}^5n*O{>nP5vEa^|F=zO74c9SyZAG>OuX{ zwPYg=tGZZ(yov)46}T=As;D6E;0H4&RfPuqkQzv&?C4G8IG&4$t2GqE3#p301KgM0 zj27f4Xzt=L6UyuLI!o$n2_z*ZK5^6+u(9EILyU^6eQ?kTzG!ax)ROPzyY;4pNJGMV zTDxrHpIqo>zyLs6TyotSAv%Ja=RYD<#h_m$yIrPOFLSos(gluJs&MNz@(V+?RS9^a zXl5?`n(_7Is5G(i1mDo<$jvv_8|#LxrbIMeE(#FMj1VFmzQpsti8b(>`q-iu&U;D& zhSm^=!|wgdJKPQ!n>X_IHdZID#;p~bK6}H%ay5UcS;UNndZ+mAovQqMBw&%6|z8dkK2gDDHoJF$Y)5!66| zSqP6nU8C??u*Rh)(f8YZ^b))~pz9C}E_y=3lFb@W$(Vz@>&UXG%W{H+Ci`@2YkT=a zg92DbTnkp4$CZv98yB9XXFXLEzJe@JWsaYq-P4&4cUFH5{$D;OQ-m`3@lH8WEadm* zS6a(@>Xq<6&{H;9dMq@yh+BW&ZHnr9IPpD}O{u2i%mq*kisFdb&2%j3?v(Kw$`I|x zHqGN2)gFUQdKe>UwFSI3{BYK-HZ&W6HBP|ZN(QxE`R+AH3sPvH|FHhAhK-yMhtcd3ja~o?^U6 zL`PMcIi`kcYGjWjjC)-e<7>I0Wj{)ony+;egg;U6l|2B&r%`Xe`h{(A?(jqP%hz7c zSz&gZ$7pZ%qrDs9qr8eEK1@wxUYCvE!2D$vzL$riOFcFw&s@Z8MO)wXRj{sMc~p5$ ztfUF8kDg~6>Pt4Di5N+DmnVeEq@Eu})a>|IRNoEFp+x||hvaht z4oI20MrJ*+m52^n=k$V6OB}U3EJm^1wh!nuIeInVPvvdZgVpN zR1`ue#?@0gjs7h950hkn$4yh#@#yv`=y8RPn}B5izbHc?fX8|c{?G1to)Z|zpck6Q zU~kf~@t>Mre^S@^oFdC8vE;)P*BfY>_jXF?15{xufEj%lY)M_=>PN}qpN-Be)YA6z zZv8PJv3})8_|HyV%631GXg*R8R5NU|mFIUn$0D`X7ptwppYhKk4LDO9y`%|@;* znpizFK^#J{^b0-g-oXFVvPKqY{>J ztJDMpTdf-wTogCW1AnZJeg$rLtyIiTv;d%Zl9rtA73oF+{z!liSCe^#JjmP_YB(fFsH9Si)m#F#iOkKF5olG_YjTsDWZ2+b=&gJTR`DkPxnqhu-!*L-TGWZ7 zZ@3|~hOy{voUXWq$659$3IOVGd>B&q%A_F6dJR?p811K>7O%pCIr{-a;Mt1zk-6_W z-#oV)zNhyQOtXthoygVoXLd<2vW+0>m6w89_-0NLnmZb>D*$TP%F}S{%+1!6@gcV9 z+ofD&A|<(gb;1I9%goIR!6c6B&=^LbyHg%T?WQA74oimt%~US6a)1eP0YY-UB2#W^ zK%Bg?G-_+`q{m`fmLpCGtrGq=?0CgPt^OQ3GS{3W9`Fae=%(Y$;=DbC1irnWPk6%v zdm&HGv2n6OYT_(jAVXt1gpyIx9A&IQK}c`+ghg2eD6A8)P6tzsP<7M59ePuV9aErE zDi>J4siiA)!`LUJd0Fi*g@4Ixmp^X)#!=yL#+Mi~5%D~w8FI`vKQ-`EOCOZC+z=#O z@e9865(y+<#FX=6_~gPxM?Okg1AfbpZUXP| zIn&OBTd3I{Om;4*#g=DnjAcgNE4`9z1%{4I)fi>tSTctjL9st|@u3&ZBUhh(tOJH^ z5b-t19xd)>#)?%B+WnoJI@WO=G56F1qbCD>V#KD!yV-mEp}Sjowmm7?)ob5!4}U$* zn6vwCR|(m~f^67v4Vrb1$D%x(xPZPN4P(y$h-I`SgX%+4p(wu*1r3UPTd(OK+C9V) z!dGam>~6>R4`Vi_8K{lkb$Yb6&5 z6S5>mOBoNT-Ys4C^9AVqjBqxkOsR@-7DD!QV^m2K6Z7*+nR_xoB6q5K6^r{mKS}ce zSW1&W=)QC*zPUap-?aY7L)Lw43+WFlkj;yC&iSijxa3^>lf$b~%H5LXCMv-fT?BOm z_bBWmSG=D?Lz)p4td*viw8^dx&h6jce9@tn87GW*=f}Au{G3M3o<5M5RQ zU$O*CaT;wMvP~jX(%ZNJ@_=S?cY{bMse)|z#dD{Mf@^_;NkP2n7n+{T_ov`pGffnp zfY8#l(O5Ka67VMSV3@>lLA=1L#cQeuB06yWP1Rc6 z-I~X969+(+@amCBZQVe(xc5e$g(W=X999G@z>5=qCI8I_WEbC5-@IYAm~-R%f^e^! zmYUz3j^NNz_MjhzZTVHW8FyIr#Ba$>eLM`$L;*hN9&}LRKn{Qvi%~~yKR>!VLBFS+ z=2$DVIbtj0rOIUtu{+oCv%ebCnrZ^YTK<|oiY3)>2Gt4Ayc1m`34k7`lP;GS#k`6o z==UNN@irIRlEX1zOecwUm#r$iHLV)Pq^-d^m)N zv{{}P_L^hh*SIqBMi&jS?-A;hj{_B&eduPwJvT1BbxBQFo1sS;T3M0BXA;Hd#d=T_ zS3JsP2pEG7smPV#ZBSO1K2!+k^{m}Y>~7<_6-YfdvUj%DTE9^8yzt?+kh5M-ip3-7 zz-%5B0j}SGFAxZne>C@>rBBHIj0GzNx_{?bgd-;}xRvJuLdK-``Qdo!Ua>J2 z;H!S^0*M}lp1(1PyHLHVP!l%H;hs zx#slXAVf1roFDD72`J?Yd$k#}89|&!b`9~!&W4iEHzad~>S-Jlsd20B)qID1`i5(S zh;o7P)AQSSlge+n9FBaiCiK_6e`qEmPC5DIw1DL{ye6D-rofT&*0!^bQemPKlr@I5y&QJ6^qT9BT>v}9ur)cb& zXD$z__d<4t#f-_$wkB6Tq`qL-30^cIom=7VU}lW)J;^SDyS7#p>?uT2 zn_Th@_UPfSwz{fmT(;BsJZWH?09h7Cy0nSGgdF^wH_ zWpyg1rWR|2nOs4tq<*1zO4aHeP3T9t@9~INTC(^-0ykx` z^*wKd4&eQ2(8YN9wik)y3#;fwD7Wrjk5vkpv2&^!eZ$%`dq>NdW~ z)34O>oD9p5KmnceoegUUz36loTFo4+&1s37`0BvI16V5f>U$`g5Pr2PHZ`!);+T1Iz}^>K$95IVjV{;+PV zx8R*yxhK0>J19brZ8f0fIVLW0Uk9Ef6im21T{&pb*OZQpsJvd5!I~_=0zyd>5FHA) zv)kBDAH{%=-8)Y8!rw-;MfLaquN_1ZSAz}}6oaOZ zHjjHeTv@s(u5asLDa-Tf3hiS@J+q~ss;=6y=3XWC&2UBj;6yz9BjVhDE5bT)G;>wt zw7_@FT;;PVr6lKxg$!p9ybK0_<2;FF$8!2R*L=ZoVTN_Gh0!90?ggwxyB4;lb4Vu9 zH_{;d!qIJDd2xs1kF$6U9^|rH5Q#00(w!DB&I&h%D-gT)!uZ|W)rR6Jj8#5gmAbpR zDRta^{3?;kbk7L!&E@pgH4RuSd}lDxij*PuLSqowkkkolDmzB=RFKjaIN!I0sV2*{JE}#E#eU1f{JN z1UU~hMjZ>L^`Y;Mh1y^XJ>RSk3beJD@&*kn5kd@*xh9(F9tR&()bYlxa?e$V5zW!R z0hyddl;oq4Pv(E~NSZ_CVL%q12!Yz|_gXa1uS1YpS-ATdWuzsgl&cqArYbz3UclwB zWtd1l>|JpX*!HI>>l zz8wFg-P!70hTg_5*5X^0xB(Rv%(Haxw>m(qEm1^{*>y12t$^s57^PjP(9((8v}mq2 z)?*;b!Kg+l%|`kN_!^AVy)mx_THy6JZ8jfla4<6qTvR;a_!z`wT2Fnpm15Y%WVWWC zqUqbvCJb?J*Dmce;qnu{N;93a;z~QCK%ER`)Z(?iTLf`=t<@ec90v_|n_A{~KG4Q7 ztnwHGyS^)#yIJd4`TGmF)pquxw*muFsOMueOINz<@x?&{w2;{AjhcLJZ}0wh$>@*H zyomrCqFAXp@m4Y64t@lO!>c1TQQyZ!$qi;6dwJo}nu}(q1_>lCbRKhOpz|26$g~mr zl~oQo1C9mZ#I^lhA-RMGdVORp6E?%Ej&M0VOcyf{vO5MMQV(p^*%9POxiR%b88%|S zIEn+xN@Y0RbYZrR9R#sn{TpP(M#^#iR%37thos*l?Ad-uE-o2}?|%)3>_y|uhgL0; z|06-sorC>|&rJ4K-T*m3#=m#LRv#uCq+UsTBnijXGR>Fz6rF$=jA;}@)+@kTiE9)9 z=ngyih0g}Uw($*;i-X)+0k9{`Ip`zNR-!(#d7$;%#U6QB;}?oWHn`+p>nZ{fzCHE&Z=mN|&s-|H*%f41W4R}*qZ)h%NbE1qS-aA6d?R?FCO z`SKiSjJ%M8YqGsq@zTT#5yp=53Y-*&SNyUawMQ3igiDT{HS6VwowK6I%r~Qz>fmoG zAm7!5e2#dhByUT!-lLDm*7rifM+d;bp}8xky{p@@-2J|Asc83;g8fQ@29A37=7&|{U^ojIi&_L^->jNBYr0vf^w0d-)<)*f1`lZ@?O+?S@3EO7VYpu=8FrnC@oZSSDA%Qdz>HEdT_S}-rM$R{WsA!3dsrQ!_{s`^H>|IdO>+J7fR>Sc#^1gOk{WlgO@jY?4Rs9$c z46piX8k#RBVH&sxQp_^s(`H#axMcid$L6J6=8Ast0&1$e`R3zUH?UBu@6NdpT__N6 zQ4F1kXy$K9;lCOfEOgb&mU%31!S20G{Zk0F8vjzj$(koV4C%w_iYcpmy6R{C>I4K@S11PRpkY!4Yv5-MN-d}1z9NCCCjrf?cPMc&R8MjbBoj%|Z&Sx`uo8?+Qd_mzFHF_GCfRL=Ik+7&D%n0aE)ys%WVPMJ0 zXtI=17-9}>=`6NMCfD3>t@Q!b3kztL<7$;dlwS5NAk8)yP3jSHUZ7N5Q!^unNQAidoUz*&4%4LzLr!hJ%gI zh-EFW&q4LJ@WOFNc=I9vy2J9>mYHS+MKd)YL~U);Xp`2Pr(snuBsEh%_o~psX$1>x zTeDxgPPLNcgGQP5MJ{UcA;hFOD5Ed{tD@`+3}e4GtvPhr5w3O8z{%*L8x7CE5{t1N zft}WMZ?CQkxHjG7kQ0S>pCVRquZ>q6Wb0+ieqlmCV~&$#x3iYot59FNP!Zq-cL$fR zns(JFk=s)=ki`e>eo|Nzd-)~h_k8T5su}Ohh?kZtz7xcs;{TbB3oFqz9}#gwe%IPf z_4NT&hvJgLj;zG%-LQY3Tqf}`xtyra`6+WjVhem%%!)N9;K4XF|Ns6RHQon&+n<^e?N znSl(zf@rb@)n0WOSJ1>p!CrAcGU{V5H!CQ9S5ed;t5>|hIXFcba`CbU8{1!Vls}li z)M={=8+TyhDt`WHQ_u}XnuCWwKk#NRoT#rZsZr_dT>aP%V)4h$g`Tr~iIaKKVqdH~ zdozX~jq?tXHl<(De)G8UGTDmrvDxwyBi=su%wk)OIODpG0DKhkBWtn^lY1)vI!Su$ zbIXV=hGW4cgpR!Nl*4|t^tPmWC&tJa=Ds%MIk1%F;=V5bB!KeJ)%O~1#7XCE|zJ6U=@@tmp{Tpaj(fB)08kJeXv=Z^7e-q@=|$pPy$O>i#R1~&_L1xTB& zkfw2g_MzO`uh^r$Zf9A&v$g0%73jC@V27fOUKbZm_pJx&9`_F?tD@~!nzo^XP0l?y z8LV=EiY#EgbDOj`Vw&iVzx6Z+9!(s#seYC;3qyUadR5HBw~fnY-lKoy(XLhEt4j$L zG*pVQo|=J%F&4_d;$_d~oQ}I5-%JCe3MA8+D@ESI(2}%cUByvWkpP{g(*B4UI_|-N zLzGjmV$vw9uwtWJ zTEpOF)j_$IT5z;EQmVcta{XFRh-{C6&a_lImpg8jt!pU>m$*0i&`o*5#JEKR5*-EP zsZb*8^4T z0ZC;@BSc|y6C$jJXm5-!$?yL0b!AAuy^peh z5M(Q_(g^PIKMH#83eBbecCz20#wX%Twm&9*z@bkQj{EKe+1bALIASuWVO^DcnL>kJ z^!joIA=XW_@JSllo1RRJ-NeGUvHf*@zGdtlT|+(-8VoKbh}bAOIk*~kQRHcG-cO13 zik~#2Y{oX%f70ev>ps<>IeGQpJvvtZX`8p(9T(DjS0~xOqSw{xeoEPjYbSh(Q}0UA zO>&lTLn7d$^;6~gEgfLT$PVm-+OKkXVhxiSOvzQaiU%BglKjPh?s!SRAShTO<2f8S zzCZhiN`DF&%98&O!A>3i{FTxv;PCni>T4$sg`27wZiq(?KC={^w$F?QDwaHMMco1H zX=f19+!lDiZIuo}csU{m6^k z6}(BU}MT?+j)IPi+tbeV#x;=lEQKwK+xMn2i5z zao-P7-EVHxgBZx5-@R<<-X!P6SC#0v)p}c_|L43>gP0d4w%be+|4ew(QqQzN@$i=H;gQ{b^}C`XBfCed(_6u|bCB{v*N$ zW`Ain$=xX|tS-+-$U4GoGyXP3xJw5AOCf|l(t0sRJ{}rFHgoK&DMiF)?uvcjxW8z( zM4~_2px(^qA-nC);R8EKM=@Gd+UEzGcT7{BZ>nLS>xhQQAIxoMXjtvk8a(2ndZs#@ z>J-gYoS3Rdq2u-cT(Sc%htILyytn3SL_9||RHVvS^m1#bsGXwA=g6d`jg^vkNExPy z0v)pvlBmP9Bm<@1{E~SN`nfNoImc`YH0a%d)hdb&YxzTGG-eKY*+5%R#)>pQC%0f= z1Pk8E4i1~P*9rxBXhQ#cQ77=6&(80lI7WuG<+f7e(e_nJag}ZFh?sjh`^G zGSbQJ(Q9m?!!Q@;W=}zzabJMGWcD@?TTPyujU@%M^^99k-3}nRh{pPN6TRF<=eMf~ z2t~IDg*VUHz;vpZ<;fcJjrT$|5bsFYNQR~#Z05|Istvic(3Y(e*JGmsg=J`jZ2*Cd zX-2nd##nid)#CT|1D(WC1})%hk?)4es8xNhKZhjOYDnqeTa)KX4j!P5!mOMAT>E3SPOY*yP~tAlW5&o0q&7zk zV^9yYq^c(=?^kqO4pwP>3^~19&~$Q<{Cjq^E#9|Q;k>qAr9&)%P+9i6aW`2-jQ;?% zdqFM{#pB-E3hQ-!Epv2MvzipV*q&WsYPOabye!RO!oU~7mx2a;$oL-0(fGI0CZ2Se zL35{yH26zeIa!r4ebGn5)ufE!Pw}d*?kU#iZK|>^i z|Dl_`+jm!^2|1+Dxoxa_^*4y5wN-_fZjslZV1KcY$R+uxOG zUYJa1v(Y7L?O*ily%*&=*eXuyuzzl3&N7iKoQdpIoUm{MP^yhfm+jqXJP}*^)WHLC4#sjZyc=3Bw%C%^ zxtq-;3^hv&9Xk!HoaJ&SwSz6GKW%6FByC;ON(ZU5MkprMyQ!k~+pgaH^)`j$^nSO; zOBiN~9Wpu!i>$4vcY*DJmw^!8OJQKJxUj^Pk+dlLjxo_Bx0qjh-xoV3V%CP9ZOWe` zgxB8VR6m-q(jURdmcw=1<_{UMaj`A7wLKmYSglEVer`X*_1$;>_9;SZ83MJ|r=V`} zA5CYk1^!lo82`TxB6>l7h-kjxiG%mejx>R(c_0dC9ul$f#(TX@`z2bjL)p129oi)B z?790Sig@{?A?mCQ58fRieU1|Oz7U3}eA`wDa7J9Sq0Gu4?@;lW46mN86!_4E%L=X@oKcS~Fubr}D*>e4T9IhIPX)x;F1G)!E!POA~oa z*IV^J7tN#0*IDJBQnQFhIs7oTIWn*b?RDYDNMh%1M5SwOjdrXW4}Uy{KdYF-!ICHS zA1dt_qU^9_vUJ9TBE88Cny8Rz=)P8KKyh?`E8f>He!!q5F!|y|MNhp6-k;T`;ar~P zbMQ`J9QCyx5h>6sK>){D?vw=3x7B1lPQ-&e25o-B`ZBu1Ln>$tVfw5hZszAF0mTC0 z&AzIA*cImGTRL9q5!8eMzJ#95^F(^%QqHZB%kRq^&)h;GP<#QIJlD55WMSQ!e*ii8 z^DuBNi|t<*n*hK#ZT+ew1_ft)iIWdQ0FQ(FBJNyE#2+|taU+f#04fmYK$R&LEI0Wa z07L8KY|1^Jb9mVohfKB2z)Q&hJ|fIgPLsKhz`JLsWjJn&Owfh{?by((Pbxf4DnNj+ojXA*o1S{oZ3T7+ zj`{;EvkBgVH=3)7M_8122u${K+gnE_->|RSmd(*RFVXltxJJWO{w4Sg`V@d}{Tw41 zvn}67Lp%cCnN$c-`vZc>{W8hnJ3_t6-I|pDHEVNPVz$C7!1vDtSZ=-Zkl4fI@(j8} zSRzgBuy5A-;{*|D`vbVVHf z>XXi>EiiUo7+5vi_NPUS0(+hJ%`&UI)Nyl61K=|PwX{2y_sR>3wZsbJAK8{P@D$KX z-h5pAsbrFKR84qe^ zDMS(6JsMmq1{f*d6iULVA{%w-t?dfz?u)U#pp^KiQulE1YQ!|Mm0--f8L1`UDVP6Z z$=gTb2jGwCY-Z2V*uMNOz;PKr=4=VfSoY{(Em3v45gv>%2AYkjK*!^^avUlmsCL#~ zucl&T`Kgvuf0aK)wr~Ctd0m!PtWq-un-t}qdAYTPtkn7Rw40imIsD%595Y#AuA@L)l4_J7Xt~F0L@D)>`-3Lvt6_TI!tv z4y*-@>^;(VDn5P?OEuQb;g5<124rYd{^6*##`Yz-T~B?jfDd%`M?boF$i!t9D{-D` zOi85ehD&V-npz;H=9~tRs7W??hYdFF2*voy}fG^K=ehHcOFAV{kN)>&7PLYa;%88H$@ z6*(N>t`yUEOWGd{G}*=_!fNC%0EivDDy=?@8_8zMd|y!~S>a6O1)6G;0tHYg-Lo(Z zBD0Erm=UIAVKv_8J{9ue_yB7{V<$H1Ftc&n8S{&o$%*LLi$uEE^`1I*&rD8u7{SE7 z7OaW&ghS%g1+zE%L)Toj&2K5DQQ7WSW79EmyG1`dx!hhDocmg!6s(CI?2)HUI{43) zarR65r9GWQpRfwfhj9^Ew$OLpF^Ix4LRb)xZ{R5Ep^C$)tW`%hZbCL0>mub3l3Mf% z8X!2B^j~`3PaHIv6?k~(+$@~3e09W(Y7mx3#k-96q`+9(6;JzyT)Es3@=of$ zQEHoHYd?#D4~?}I$fAl3mucb4?zddLNROhq6<0z$(g1BM=AsBf7~$=r@!PLC)~=`m z3lm-5vIS#Z&Vm!8Fk9GWYsDlo?sV5?opRG(Yhqg@2Qu~D!L{m+^ELN|-z1%&oZ+Im z>zRzH9r!#{Z+A}BF{EgB&Y>F<=qk5oZ94&s2YLe<%$wzpWq+lWuADr(|{Y%*70 zt8G9UnhC_jQ3q9ObOCsi>G%>Ge|2c85p#$mJ&du&6`dWkg&X5yBcS@;CI=0PmvFP! z$p*(uj2XmT+F;2!HE~F!nFn_*pfjebQo*mt`rj1vF$ys4zjnm zh@%j$c`87GnYEtaiu=SU1hAoqbVh%6BczdEyCm#u!ksUP?>J-vXDou6A9+y-#5?Z7 z38HJe8?yVWR)%|vVW64aRdP#DdI(q?s}P+CMN+x5Xj()YcwF1swvFY=Xdbg{t6UCn z=0S>zHh!~s(Nuj^53C+#`{D=4TI41=;yN8guFMyai95eAgz zE9P>s@`}AfkT-#3l{;zcov{xWo8FvUgbAdxVW30#8ZTWq%Jf#fu_BW*P-UcX^bg62 zoLVdpRD;`0wy4=%^%ga8dokk+MGyvH=?-uTt!?h0XT$hwb)187-nzc(8Ky0@FJ0ht zYKuYM7W%Xr+ghcGA#30ehc4Vc3U&t}SFrWqL8>Dfe-(MEf&dj<^&x6ihC25iCm|T4=C8Q5aKM9cgYF1GsmGZthdpT@DUq4KA`2Hj2IF9{52�zqs~>kwVv@QxJ$UNc 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�rbLOqMk92#`K^>QP47JOh=bG!vHb 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�A`l4 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