Wenchao An 1b76ab9060
feat: add opt-in task notes and compacted history recall (#5382)
* feat: add opt-in task notes and compacted history recall

* fix: validate task continuity state and preserve user answers

Honor explicit opt-out, preserve clarification replies and capture failure statuses, validate notebook writes, and clear branch archive references. Update the config version and audit optional LLM credentials, with regression and integration evidence.

* fix: align Helm config version with task continuity schema

* fix: preserve mixed task history and declare continuity policies

* fix: recover malformed history and evict archives atomically
2026-09-12 21:01:46 +08:00

114 lines
5.2 KiB
Python

from __future__ import annotations
import asyncio
import re
import sqlite3
import time
import numpy as np
from common import PROTOCOL, ROOT, LiveClient, clip, tokens, write_json
from prepare import record_chunks
STOP = set("a an the is are was were be been being to of for in on at by with and or as from this that these those it its i me my you your we our they their he she his her do does did have has had what which who whom whose when where why how can could would should will shall about please tell give many much than then there here any some all also into after before during".split())
def lexical_terms(text: str, *, query: bool = False) -> list[str]:
text = re.sub(r"([a-z])([A-Z])", r"\1 \2", text)
latin = re.findall(r"[a-zA-Z0-9]+", text.lower())
cjk = []
for run in re.findall(r"[\u3400-\u9fff]+", text):
cjk.extend(run[i:i + 2] for i in range(max(1, len(run) - 1)))
terms = latin + cjk
return list(dict.fromkeys(t for t in terms if t not in STOP)) if query else terms
class HistoryIndex:
def __init__(self, records: list[dict], case_id: str):
self.records = records
self.by_id = {r["id"]: r for r in records}
self.chunks = record_chunks(records, PROTOCOL["archive_chunk_tokens"], PROTOCOL["archive_chunk_overlap"])
self.case_id = case_id
self.db = sqlite3.connect(":memory:")
self.db.execute("CREATE VIRTUAL TABLE history USING fts5(body, tokenize='porter unicode61')")
self.db.executemany("INSERT INTO history(rowid, body) VALUES (?, ?)",
[(i + 1, " ".join(lexical_terms(c["text"]))) for i, c in enumerate(self.chunks)])
self.vectors: np.ndarray | None = None
self.embedding_seconds = 0.0
def keyword_ranks(self, query: str, limit: int = 80) -> list[int]:
terms = lexical_terms(query, query=True)[:60]
if not terms:
return []
expression = " OR ".join('"' + t.replace('"', '""') + '"' for t in terms)
rows = self.db.execute("SELECT rowid FROM history WHERE history MATCH ? ORDER BY bm25(history), rowid LIMIT ?", (expression, limit))
return [row[0] - 1 for row in rows]
async def build_vectors(self, client: LiveClient) -> None:
if self.vectors is not None:
return
start = time.monotonic()
# Bound each request by both item count and practical token volume.
batches = [self.chunks[i:i + 32] for i in range(0, len(self.chunks), 32)]
result = []
for i, batch in enumerate(batches):
result.append(await client.embed([c["rendered"] for c in batch], tag=f"{self.case_id}:index:{i}"))
self.vectors = np.vstack(result) if result else np.empty((0, 1024), dtype=np.float32)
self.embedding_seconds = time.monotonic() - start
def pack(self, ranking: list[int], budget: int) -> list[dict]:
selected, used, per_record = [], 0, {}
for index in ranking:
chunk = self.chunks[index]
rid = chunk["record_id"]
if per_record.get(rid, 0) >= 2:
continue
n = tokens(chunk["rendered"]) + 2
if used + n > budget:
continue
selected.append(chunk)
per_record[rid] = per_record.get(rid, 0) + 1
used += n
return selected
async def search(self, query: str, mode: str, client: LiveClient, budget: int | None = None) -> dict:
budget = budget or PROTOCOL["retrieval_context_tokens"]
start = time.monotonic()
lexical = self.keyword_ranks(query)
dense = []
if mode in {"hybrid", "dense"}:
await self.build_vectors(client)
query_vector = (await client.embed([query], query=True, tag=f"{self.case_id}:query"))[0]
scores = self.vectors @ query_vector
dense = sorted(range(len(scores)), key=lambda i: (-float(scores[i]), i))[:80]
if mode == "keyword":
ranking = lexical
elif mode == "dense":
ranking = dense
elif mode == "hybrid":
scores = {}
for ranks in (lexical, dense):
for rank, index in enumerate(ranks, 1):
scores[index] = scores.get(index, 0) + 1 / (60 + rank)
ranking = sorted(scores, key=lambda i: (-scores[i], i))
else:
raise ValueError(mode)
hits = self.pack(ranking, budget)
return {"mode": mode, "query": query, "hits": hits,
"tokens": sum(tokens(c["rendered"]) + 2 for c in hits),
"seconds": time.monotonic() - start,
"ranking_ids": [self.chunks[i]["id"] for i in ranking]}
def read(self, record_id: str, radius: int = 1, budget: int = 1500) -> dict:
rid = record_id.split("-c")[0]
if rid not in self.by_id:
return {"error": "unknown_record_id"}
position = next(i for i, r in enumerate(self.records) if r["id"] == rid)
radius = max(0, min(radius, 2))
selected = self.records[max(0, position - radius):position + radius + 1]
text = "\n\n".join(f"[{r['id']}] {r['date']} {r['role']}\n{r['content']}" for r in selected)
return {"record_id": rid, "text": clip(text, budget), "truncated": tokens(text) > budget}
def close(self) -> None:
self.db.close()