Add Projects, Milestones, RAG auto-inject, push notifications, PWA, tag normalisation
## Projects & Milestones (Phases A + G) - New models: Project, Milestone (Project → Milestone → Task hierarchy) - notes table: project_id + milestone_id FKs; parent_id FK constraint activated - Migrations: 0017 (projects), 0018 (push_subscriptions), 0019 (events), 0020 (milestones) - Services: projects.py, milestones.py (CRUD + progress tracking) - Routes: /api/projects + /api/projects/<id>/milestones - LLM tools: create/list/get/update project; create/list milestone; project + milestone + parent_task params on note/task tools - Frontend: ProjectListView (stacked milestone bars), ProjectView (milestone-grouped kanban), ProjectSelector, MilestoneSelector, NoteEditorView + TaskEditorView updated ## RAG Auto-injection (Phase B) - Notes ≥0.60 cosine similarity auto-injected into system prompt (max 3, 800 chars each) - excluded_note_ids param; ChatView "Auto-included" sidebar section ## Summarisation improvements (Phase C) - Threshold 20→30, keep-recent 6→8, max_tokens 200→400 - Two-pass summarisation for histories >50 messages ## Browser push notifications (Phase E) - PushSubscription model + migration; pywebpush dependency - /api/push routes; VAPID config; fire-and-forget on generation complete - Frontend: sw.js, push store, Settings toggle ## PWA manifest (Phase F) - manifest.json, Apple meta tags, service worker registration in main.ts ## Tag normalisation - All tags lowercased + deduplicated at backend (create_note/update_note) and frontend (TagInput sanitize) - Note/Task types gain project_id + milestone_id fields; store signatures updated ## CalDAV - Radicale embedded server reverted; back to user-configured external CalDAV Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,10 @@ STOP_WORDS = frozenset({
|
||||
"but", "if", "so", "than", "too", "very", "just", "about", "up",
|
||||
})
|
||||
|
||||
RAG_AUTO_THRESHOLD = 0.60
|
||||
RAG_AUTO_LIMIT = 3
|
||||
RAG_AUTO_SNIPPET = 800
|
||||
|
||||
|
||||
async def get_installed_models() -> set[str]:
|
||||
"""Return set of installed Ollama model names (with and without :latest)."""
|
||||
@@ -302,8 +306,8 @@ def _find_urls(text: str) -> list[str]:
|
||||
|
||||
|
||||
# History summarization thresholds
|
||||
_HISTORY_SUMMARY_THRESHOLD = 20 # total messages before summarizing
|
||||
_HISTORY_KEEP_RECENT = 6 # verbatim tail to preserve (3 exchanges)
|
||||
_HISTORY_SUMMARY_THRESHOLD = 30 # total messages before summarizing
|
||||
_HISTORY_KEEP_RECENT = 8 # verbatim tail to preserve (4 exchanges)
|
||||
|
||||
|
||||
async def summarize_history_for_context(
|
||||
@@ -324,13 +328,55 @@ async def summarize_history_for_context(
|
||||
to_summarize = history[:-_HISTORY_KEEP_RECENT]
|
||||
recent = history[-_HISTORY_KEEP_RECENT:]
|
||||
|
||||
lines: list[str] = []
|
||||
for m in to_summarize:
|
||||
role = m.get("role", "")
|
||||
content = (m.get("content") or "").strip()
|
||||
if role in ("user", "assistant") and content:
|
||||
label = "User" if role == "user" else "Assistant"
|
||||
lines.append(f"{label}: {content[:400]}")
|
||||
# Two-pass for very long histories: summarize first half, combine with second half
|
||||
if len(to_summarize) > 50:
|
||||
mid = len(to_summarize) // 2
|
||||
first_half = to_summarize[:mid]
|
||||
second_half = to_summarize[mid:]
|
||||
|
||||
# Summarize first half
|
||||
first_lines = []
|
||||
for m in first_half:
|
||||
role = m.get("role", "")
|
||||
content = (m.get("content") or "").strip()
|
||||
if role in ("user", "assistant") and content:
|
||||
label = "User" if role == "user" else "Assistant"
|
||||
first_lines.append(f"{label}: {content[:400]}")
|
||||
|
||||
if first_lines:
|
||||
try:
|
||||
first_summary_messages = [
|
||||
{"role": "system", "content": "Summarize this conversation in 3-4 sentences covering topics, notes/tasks created, and key decisions."},
|
||||
{"role": "user", "content": "\n".join(first_lines)},
|
||||
]
|
||||
summary_a = await generate_completion(first_summary_messages, model, max_tokens=300)
|
||||
summary_a = summary_a.strip()
|
||||
except Exception:
|
||||
summary_a = ""
|
||||
else:
|
||||
summary_a = ""
|
||||
|
||||
# Build lines for final pass from second half
|
||||
second_lines = []
|
||||
for m in second_half:
|
||||
role = m.get("role", "")
|
||||
content = (m.get("content") or "").strip()
|
||||
if role in ("user", "assistant") and content:
|
||||
label = "User" if role == "user" else "Assistant"
|
||||
second_lines.append(f"{label}: {content[:400]}")
|
||||
|
||||
if summary_a:
|
||||
lines = [f"[Earlier summary: {summary_a}]"] + second_lines
|
||||
else:
|
||||
lines = second_lines
|
||||
else:
|
||||
lines: list[str] = []
|
||||
for m in to_summarize:
|
||||
role = m.get("role", "")
|
||||
content = (m.get("content") or "").strip()
|
||||
if role in ("user", "assistant") and content:
|
||||
label = "User" if role == "user" else "Assistant"
|
||||
lines.append(f"{label}: {content[:400]}")
|
||||
|
||||
if not lines:
|
||||
return history, None
|
||||
@@ -339,17 +385,19 @@ async def summarize_history_for_context(
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Summarize this conversation history in 3-5 concise sentences. "
|
||||
"Capture: topics discussed, any notes/tasks/events created or modified, "
|
||||
"decisions made, and context needed to continue the conversation naturally. "
|
||||
"Be specific and factual. Output only the summary, nothing else."
|
||||
"Summarize this conversation history. Capture: "
|
||||
"(1) All notes, tasks, and projects created or modified — include their exact names. "
|
||||
"(2) Key decisions made and conclusions reached. "
|
||||
"(3) Open questions and next steps mentioned. "
|
||||
"(4) The overall topic arc so the conversation can continue naturally. "
|
||||
"Be specific and factual. Output 4-8 concise sentences. Nothing else."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "\n".join(lines)},
|
||||
]
|
||||
|
||||
try:
|
||||
summary = await generate_completion(prompt_messages, model, max_tokens=200)
|
||||
summary = await generate_completion(prompt_messages, model, max_tokens=400)
|
||||
summary = summary.strip()
|
||||
if summary:
|
||||
logger.info(
|
||||
@@ -371,6 +419,7 @@ async def build_context(
|
||||
exclude_note_ids: list[int] | None = None,
|
||||
history_summary: str | None = None,
|
||||
include_note_ids: list[int] | None = None,
|
||||
excluded_note_ids: list[int] | None = None,
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""Build messages array for Ollama with system prompt and context.
|
||||
|
||||
@@ -426,6 +475,7 @@ async def build_context(
|
||||
"context_note_id": None,
|
||||
"context_note_title": None,
|
||||
"auto_notes": [],
|
||||
"auto_injected_notes": [],
|
||||
}
|
||||
|
||||
# Include current note context if provided — full body, no truncation
|
||||
@@ -441,10 +491,9 @@ async def build_context(
|
||||
f"--- End Note ---"
|
||||
)
|
||||
|
||||
# Search for related notes to populate sidebar candidates.
|
||||
# Results are NOT injected into the system prompt automatically — this keeps
|
||||
# the system prompt prefix stable so Ollama's KV cache can reuse prefill state.
|
||||
# Users can explicitly include notes via the sidebar (include_note_ids).
|
||||
# Search for related notes. High-confidence results (>=0.60) are auto-injected
|
||||
# into the system prompt; lower-confidence results populate the sidebar only.
|
||||
# Users can also explicitly include notes via the sidebar (include_note_ids).
|
||||
search_exclude = set(exclude_set)
|
||||
if current_note_id:
|
||||
search_exclude.add(current_note_id)
|
||||
@@ -473,12 +522,54 @@ async def build_context(
|
||||
except Exception:
|
||||
logger.warning("Failed to search notes for context", exc_info=True)
|
||||
|
||||
# Populate sidebar candidates (never auto-injected into system prompt).
|
||||
# Separate high-confidence results for auto-injection vs sidebar display
|
||||
excluded_inject_set = set(excluded_note_ids or [])
|
||||
auto_inject: list[tuple[float, object]] = []
|
||||
sidebar_only: list[tuple[float | None, object]] = []
|
||||
|
||||
for score, n in found_scored:
|
||||
if (
|
||||
score is not None
|
||||
and score >= RAG_AUTO_THRESHOLD
|
||||
and len(auto_inject) < RAG_AUTO_LIMIT
|
||||
and n.id not in excluded_inject_set
|
||||
):
|
||||
auto_inject.append((score, n))
|
||||
else:
|
||||
sidebar_only.append((score, n))
|
||||
|
||||
# Inject high-scoring notes into system prompt
|
||||
if auto_inject:
|
||||
snippets = []
|
||||
for score, n in auto_inject:
|
||||
body_snippet = (n.body or "")[:RAG_AUTO_SNIPPET]
|
||||
snippets.append(f"**{n.title}** (relevance: {round(score * 100)}%)\n{body_snippet}")
|
||||
context_meta["auto_injected_notes"].append({
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"score": round(score, 2),
|
||||
})
|
||||
system_parts.append(
|
||||
"\n\n--- Relevant Notes ---\n"
|
||||
+ "\n\n".join(snippets)
|
||||
+ "\n--- End Relevant Notes ---"
|
||||
)
|
||||
|
||||
# Populate sidebar candidates (auto-injected notes also appear here for reference,
|
||||
# but sidebar_only are the ones not yet in the prompt)
|
||||
for score, n in auto_inject:
|
||||
context_meta["auto_notes"].append({
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"score": round(score, 2) if score is not None else None,
|
||||
"auto_injected": True,
|
||||
})
|
||||
for score, n in sidebar_only:
|
||||
context_meta["auto_notes"].append({
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"score": round(score, 2) if score is not None else None,
|
||||
"auto_injected": False,
|
||||
})
|
||||
context_meta["auto_note_ids"] = [n.id for _, n in found_scored]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user