feat(clustering): persistent enrichment cache in task_enrichments table
Each unique task title is now enriched by LiteLLM once and cached in the DB. Subsequent agent compute cycles (every 12h) fetch the cache before calling ml-serving; only new titles hit the tip-generator. - DB: task_enrichments(content_hash PK, description, model, created_at) - TS: fetchEnrichmentCache / persistEnrichments helpers in agent-outputs.ts; enrichment_cache passed in compute request, new_enrichments persisted from response - Python: AgentComputeRequest.enrichment_cache / AgentComputeResponse.new_enrichments; AgentInput.enrichment_cache; _enrich_batch returns (descriptions, new_entries); cluster_tasks returns (clusters, new_enrichments) - FocusAreaAgent stashes new_enrichments in signals_snapshot under _new_enrichments; compute_agent endpoint pops it before storing the snapshot Closes part of #129 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -87,26 +87,41 @@ def _enrich_title(title: str, litellm_url: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _enrich_batch(titles: list[str]) -> list[str]:
|
||||
"""Return enriched descriptions for each title; falls back to raw title on failure.
|
||||
def _enrich_batch(
|
||||
titles: list[str],
|
||||
persistent_cache: dict[str, str] | None = None,
|
||||
) -> tuple[list[str], dict[str, str]]:
|
||||
"""Return (descriptions, new_entries) for each title.
|
||||
|
||||
Results are cached in-memory by content hash so duplicate titles within
|
||||
a single compute() call cost only one LLM round-trip.
|
||||
Checks persistent_cache (pre-fetched from DB) first, then falls back to
|
||||
calling LiteLLM. new_entries contains only hashes generated this call —
|
||||
the caller should persist these to the DB.
|
||||
"""
|
||||
litellm_url = os.getenv("LITELLM_URL")
|
||||
if not litellm_url:
|
||||
log.debug("enrich_batch: no LITELLM_URL, skipping enrichment")
|
||||
return titles
|
||||
return titles, {}
|
||||
|
||||
cache: dict[str, str] = {}
|
||||
db_cache = persistent_cache or {}
|
||||
session_cache: dict[str, str] = {} # dedup within this call
|
||||
new_entries: dict[str, str] = {}
|
||||
results = []
|
||||
|
||||
for title in titles:
|
||||
h = _content_hash(title)
|
||||
if h not in cache:
|
||||
if h in db_cache:
|
||||
results.append(db_cache[h])
|
||||
elif h in session_cache:
|
||||
results.append(session_cache[h])
|
||||
else:
|
||||
desc = _enrich_title(title, litellm_url)
|
||||
cache[h] = desc if desc else title
|
||||
results.append(cache[h])
|
||||
return results
|
||||
value = desc if desc else title
|
||||
session_cache[h] = value
|
||||
if desc: # only persist successful enrichments
|
||||
new_entries[h] = desc
|
||||
results.append(value)
|
||||
|
||||
return results, new_entries
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -227,14 +242,17 @@ def _fallback_by_project(tasks: list[dict]) -> list[Cluster]:
|
||||
def cluster_tasks(
|
||||
tasks: list[dict],
|
||||
ollama_url: str | None = None, # kept for test compatibility; env vars take precedence
|
||||
) -> list[Cluster]:
|
||||
enrichment_cache: dict[str, str] | None = None,
|
||||
) -> tuple[list[Cluster], dict[str, str]]:
|
||||
"""Cluster tasks by semantic similarity.
|
||||
|
||||
Returns a non-empty list of Cluster objects. Falls back to project-based
|
||||
grouping if the embedding service is unavailable or tasks have no content.
|
||||
Returns (clusters, new_enrichments). new_enrichments contains LLM-generated
|
||||
descriptions produced this call that were not in the persistent cache — the
|
||||
caller should persist these. Falls back to project-based grouping if the
|
||||
embedding service is unavailable or tasks have no content.
|
||||
"""
|
||||
if not tasks:
|
||||
return []
|
||||
return [], {}
|
||||
|
||||
# Separate tasks with usable content from those without.
|
||||
with_content = [(t, t.get("content", "").strip()) for t in tasks]
|
||||
@@ -242,13 +260,13 @@ def cluster_tasks(
|
||||
no_content = [t for t, c in with_content if not c]
|
||||
|
||||
if not embeddable:
|
||||
return _fallback_by_project(tasks)
|
||||
return _fallback_by_project(tasks), {}
|
||||
|
||||
task_objs = [t for t, _ in embeddable]
|
||||
raw_titles = [c for _, c in embeddable]
|
||||
|
||||
# Step 1: LLM-enrich titles → richer semantic signal before embedding.
|
||||
descriptions = _enrich_batch(raw_titles)
|
||||
descriptions, new_enrichments = _enrich_batch(raw_titles, persistent_cache=enrichment_cache)
|
||||
|
||||
# Step 2: Prefix with nomic-embed-text task prefix, then batch-embed.
|
||||
prefixed = [f"clustering: {d}" for d in descriptions]
|
||||
@@ -256,7 +274,7 @@ def cluster_tasks(
|
||||
|
||||
if vecs is None or len(vecs) != len(prefixed):
|
||||
log.info("cluster_tasks: embedding unavailable, falling back to project grouping")
|
||||
return _fallback_by_project(tasks)
|
||||
return _fallback_by_project(tasks), new_enrichments
|
||||
|
||||
embedded = list(zip(task_objs, vecs))
|
||||
clusters = _greedy_cluster(embedded)
|
||||
@@ -264,4 +282,4 @@ def cluster_tasks(
|
||||
if no_content:
|
||||
clusters.append(Cluster(label="Other tasks", tasks=no_content))
|
||||
|
||||
return clusters
|
||||
return clusters, new_enrichments
|
||||
|
||||
Reference in New Issue
Block a user