mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 09:08:38 +00:00
* 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
114 lines
5.2 KiB
Python
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()
|