feat(consolidation): full consolidate_task with background model

consolidate_task reads the task title, description (read-only context),
and chronological work logs; builds a prompt via _build_consolidation_prompt;
calls generate_completion with the user's background_model setting; on a
non-empty result, writes back to Note.body, stamps consolidated_at, and
re-runs the embedding pipeline.

Errors are caught and logged. LLM failures leave body untouched so the
next trigger retries cleanly. Per-task asyncio lock prevents simultaneous
passes for the same task.
This commit is contained in:
2026-05-13 12:12:37 -04:00
parent 5419330633
commit bda6e6c80f
2 changed files with 332 additions and 3 deletions
+122 -2
View File
@@ -81,6 +81,126 @@ async def maybe_consolidate(user_id: int, task_id: int, *, reason: str) -> None:
asyncio.create_task(consolidate_task(user_id, task_id))
def _build_consolidation_prompt(
*, title: str, description: str | None, logs: list,
) -> str:
"""Build the LLM prompt for one consolidation pass.
Caps total log content at MAX_PROMPT_INPUT_CHARS; logs that don't fit
are dropped. Caller is expected to slice to the most-recent window
before calling.
"""
log_lines: list[str] = []
chars = 0
for log in logs:
ts = log.created_at.isoformat() if getattr(log, "created_at", None) else "?"
line = f"- [{ts}] {log.content}"
if chars + len(line) > MAX_PROMPT_INPUT_CHARS:
break
log_lines.append(line)
chars += len(line)
return (
"You are summarizing the work done on a task. The user wrote the goal "
"below; do not restate it. Read the chronological work-log entries and "
"produce a 1-3 paragraph summary of: what was attempted, what worked, "
"what failed, what's current state. Use the user's voice; cite specific "
"commands/decisions; favor brevity over completeness. Output plain "
"markdown body content only — no preamble.\n\n"
f"TITLE: {title}\n"
f"GOAL (read-only context): {description or '(no goal recorded)'}\n"
f"WORK LOG (chronological):\n" + "\n".join(log_lines)
)
async def consolidate_task(user_id: int, task_id: int) -> None:
"""Stub — full implementation in the next task in the plan."""
raise NotImplementedError
"""Run a consolidation pass: read description + logs, write summary to body.
Errors are logged and swallowed so the fire-and-forget caller is never
interrupted; on LLM failure the body and consolidated_at are left
untouched and the next trigger retries.
"""
lock = _locks[task_id]
if lock.locked():
logger.debug(
"consolidate_task: skipping — already running for task %d", task_id
)
return
async with lock:
try:
async with async_session() as session:
task = (
await session.execute(
select(Note).where(
Note.id == task_id, Note.user_id == user_id,
)
)
).scalars().first()
if task is None or not task.status:
return # not a task, or missing
logs = (
await session.execute(
select(TaskLog)
.where(
TaskLog.task_id == task_id,
TaskLog.user_id == user_id,
)
.order_by(TaskLog.created_at.asc())
)
).scalars().all()
if not logs:
return # nothing to summarize yet
title = task.title or ""
description = task.description
window = logs[-MAX_LOGS_FOR_PROMPT:]
prompt = _build_consolidation_prompt(
title=title, description=description, logs=window,
)
from fabledassistant.services.llm import generate_completion
from fabledassistant.services.settings import get_setting
from fabledassistant.config import Config
bg_model = await get_setting(
user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL,
)
summary = await generate_completion(
[{"role": "user", "content": prompt}],
model=bg_model,
max_tokens=800,
num_ctx=4096,
)
if not summary or not summary.strip():
return
now = datetime.now(timezone.utc)
async with async_session() as session:
task = (
await session.execute(
select(Note).where(
Note.id == task_id, Note.user_id == user_id,
)
)
).scalars().first()
if task is None:
return
task.body = summary.strip()
task.consolidated_at = now
task.updated_at = now
await session.commit()
from fabledassistant.services.embeddings import upsert_note_embedding
await upsert_note_embedding(
task_id, user_id, f"{title}\n{summary.strip()}".strip(),
)
logger.info(
"consolidate_task: refreshed task %d body (%d chars)",
task_id, len(summary),
)
except Exception:
logger.exception(
"consolidate_task failed for task %d", task_id,
)