Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend + LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini -> DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials, RBAC, audit log, project-memory + Obsidian integrations, and editable troubleshooting decision rules tuned for the GeneseasX vessel stack. Co-authored-by: Cursor <cursoragent@cursor.com>
318 lines
11 KiB
Python
318 lines
11 KiB
Python
"""Load prior knowledge from project-memory and Obsidian before agent triage."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
from typing import Any
|
|
|
|
from app.config import settings
|
|
from app.services import integrations
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_STOPWORDS = frozenset(
|
|
"""
|
|
a an the and or but in on at to for of is are was were be been being
|
|
with from by as it this that these those i we you they he she
|
|
check status health task issue vessel ship
|
|
""".split()
|
|
)
|
|
|
|
_MAX_MEMORY_HITS = 8
|
|
_MAX_OBSIDIAN_HITS = 6
|
|
_EXCERPT_CHARS = 400
|
|
|
|
|
|
def extract_search_keywords(text: str, *, max_terms: int = 8) -> list[str]:
|
|
"""Pull meaningful terms from title + issue for memory/Obsidian search."""
|
|
raw = re.findall(r"[a-zA-Z0-9][a-zA-Z0-9._/-]{1,}", text.lower())
|
|
terms: list[str] = []
|
|
seen: set[str] = set()
|
|
for tok in raw:
|
|
if tok in _STOPWORDS or len(tok) < 3:
|
|
continue
|
|
if tok.isdigit():
|
|
continue
|
|
if tok not in seen:
|
|
seen.add(tok)
|
|
terms.append(tok)
|
|
if len(terms) >= max_terms:
|
|
break
|
|
return terms
|
|
|
|
|
|
def _parse_memory_tool_result(result: dict | None) -> list[dict]:
|
|
"""Normalize memory MCP tool results into a list of entry dicts."""
|
|
if not result:
|
|
return []
|
|
|
|
structured = result.get("structuredContent")
|
|
if isinstance(structured, dict):
|
|
for key in ("memories", "results", "entries", "items"):
|
|
val = structured.get(key)
|
|
if isinstance(val, list):
|
|
return [x for x in val if isinstance(x, dict)]
|
|
if structured.get("title") or structured.get("content"):
|
|
return [structured]
|
|
|
|
for block in result.get("content", []):
|
|
if not isinstance(block, dict):
|
|
continue
|
|
text = block.get("text")
|
|
if not text:
|
|
continue
|
|
text = text.strip()
|
|
try:
|
|
parsed = json.loads(text)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if isinstance(parsed, list):
|
|
return [x for x in parsed if isinstance(x, dict)]
|
|
if isinstance(parsed, dict):
|
|
for key in ("memories", "results", "entries", "items"):
|
|
val = parsed.get(key)
|
|
if isinstance(val, list):
|
|
return [x for x in val if isinstance(x, dict)]
|
|
if parsed.get("title") or parsed.get("content"):
|
|
return [parsed]
|
|
return []
|
|
|
|
|
|
def _memory_vessel_from_hit(hit: dict) -> str | None:
|
|
for tag in hit.get("tags") or []:
|
|
if isinstance(tag, str) and tag.startswith("vessel:"):
|
|
return tag.split(":", 1)[1].replace("-", " ")
|
|
content = hit.get("content") or ""
|
|
m = re.search(r"^Vessel:\s*(.+)$", content, re.MULTILINE)
|
|
if m:
|
|
return m.group(1).strip()
|
|
title = hit.get("title") or ""
|
|
m = re.search(r"^\[([^\]]+)\]", title)
|
|
if m and m.group(1).lower() not in ("task", "fix"):
|
|
return m.group(1).strip()
|
|
return None
|
|
|
|
|
|
def _obsidian_vessel_from_text(text: str) -> str | None:
|
|
if not text.startswith("---"):
|
|
return None
|
|
end = text.find("\n---", 3)
|
|
if end == -1:
|
|
return None
|
|
front = text[3:end]
|
|
for line in front.splitlines():
|
|
if line.lower().startswith("vessel:"):
|
|
return line.split(":", 1)[1].strip()
|
|
return None
|
|
|
|
|
|
def _memory_hit_from_entry(entry: dict, *, project_id: str) -> dict:
|
|
hit = {
|
|
"project_id": entry.get("project_id") or project_id,
|
|
"title": (entry.get("title") or "").strip(),
|
|
"content": (entry.get("content") or "")[:_EXCERPT_CHARS],
|
|
"memory_type": entry.get("memory_type") or entry.get("type") or "note",
|
|
"importance": entry.get("importance"),
|
|
"tags": entry.get("tags") or [],
|
|
"source": "memory",
|
|
}
|
|
return _enrich_hit_vessel(hit)
|
|
|
|
|
|
def _enrich_hit_vessel(hit: dict, *, vessel_name: str | None = None) -> dict:
|
|
vessel = hit.get("vessel")
|
|
if not vessel and hit.get("source") == "memory":
|
|
vessel = _memory_vessel_from_hit(hit)
|
|
if not vessel and hit.get("source") == "obsidian":
|
|
vessel = _obsidian_vessel_from_text(hit.get("excerpt") or "")
|
|
if vessel:
|
|
hit["vessel"] = vessel
|
|
elif vessel_name:
|
|
hit["vessel"] = vessel_name
|
|
return hit
|
|
|
|
|
|
async def _search_memory_projects(query: str, project_ids: list[str]) -> list[dict]:
|
|
hits: list[dict] = []
|
|
seen_titles: set[str] = set()
|
|
|
|
for project_id in project_ids:
|
|
try:
|
|
result = await integrations.memory_mcp_call(
|
|
"memory_search",
|
|
{"project_id": project_id, "query": query, "limit": 5},
|
|
)
|
|
for entry in _parse_memory_tool_result(result):
|
|
hit = _memory_hit_from_entry(entry, project_id=project_id)
|
|
key = hit["title"].lower()
|
|
if not hit["title"] or key in seen_titles:
|
|
continue
|
|
seen_titles.add(key)
|
|
hits.append(hit)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("memory_search %s failed: %s", project_id, exc)
|
|
|
|
return hits[:_MAX_MEMORY_HITS]
|
|
|
|
|
|
async def _recent_memory(project_ids: list[str]) -> list[dict]:
|
|
hits: list[dict] = []
|
|
for project_id in project_ids[:2]:
|
|
try:
|
|
result = await integrations.memory_mcp_call(
|
|
"memory_list_recent",
|
|
{"project_id": project_id, "limit": 10},
|
|
)
|
|
for entry in _parse_memory_tool_result(result):
|
|
hits.append(_memory_hit_from_entry(entry, project_id=project_id))
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("memory_list_recent %s failed: %s", project_id, exc)
|
|
return hits
|
|
|
|
|
|
def _obsidian_search_roots(vault: str) -> list[str]:
|
|
roots: list[str] = []
|
|
for rel in settings.obsidian_search_paths.split(","):
|
|
rel = rel.strip()
|
|
if not rel:
|
|
continue
|
|
path = os.path.join(vault, rel)
|
|
if os.path.isdir(path):
|
|
roots.append(path)
|
|
tasks = os.path.join(vault, settings.obsidian_tasks_folder)
|
|
if os.path.isdir(tasks) and tasks not in roots:
|
|
roots.insert(0, tasks)
|
|
return roots
|
|
|
|
|
|
def _score_obsidian_file(
|
|
path: str, text: str, keywords: list[str], *, vessel_name: str | None = None
|
|
) -> int:
|
|
name = os.path.basename(path).lower()
|
|
body = text.lower()
|
|
score = 0
|
|
for kw in keywords:
|
|
if kw in name:
|
|
score += 3
|
|
if kw in body:
|
|
score += 1
|
|
if vessel_name:
|
|
vslug = vessel_name.lower().replace(" ", "-")
|
|
note_vessel = _obsidian_vessel_from_text(text)
|
|
if note_vessel and note_vessel.lower() == vessel_name.lower():
|
|
score += 5
|
|
elif vslug in name or vessel_name.lower() in body:
|
|
score += 2
|
|
return score
|
|
|
|
|
|
def _obsidian_title(text: str, filename: str) -> str:
|
|
for line in text.splitlines():
|
|
if line.startswith("# "):
|
|
return line[2:].strip()
|
|
return os.path.splitext(filename)[0].replace("-", " ")
|
|
|
|
|
|
async def search_obsidian_notes(
|
|
title: str, issue: str, *, vessel_name: str | None = None
|
|
) -> list[dict]:
|
|
"""Search cloned Obsidian vault for notes related to this issue."""
|
|
vault = await integrations.ensure_vault()
|
|
if not vault:
|
|
return []
|
|
|
|
keywords = extract_search_keywords(f"{title} {issue}")
|
|
if vessel_name:
|
|
keywords = keywords + [vessel_name.lower().replace(" ", "-"), vessel_name.lower()]
|
|
if not keywords:
|
|
return []
|
|
|
|
# The vault scan is synchronous disk I/O — run it off the event loop.
|
|
return await asyncio.to_thread(_scan_obsidian_notes, vault, keywords, vessel_name)
|
|
|
|
|
|
def _scan_obsidian_notes(
|
|
vault: str, keywords: list[str], vessel_name: str | None
|
|
) -> list[dict]:
|
|
scored: list[tuple[int, dict]] = []
|
|
for root in _obsidian_search_roots(vault):
|
|
for dirpath, _, files in os.walk(root):
|
|
for filename in files:
|
|
if not filename.endswith(".md"):
|
|
continue
|
|
abs_path = os.path.join(dirpath, filename)
|
|
try:
|
|
with open(abs_path, encoding="utf-8", errors="replace") as fh:
|
|
text = fh.read()
|
|
except OSError:
|
|
continue
|
|
score = _score_obsidian_file(abs_path, text, keywords, vessel_name=vessel_name)
|
|
if score <= 0:
|
|
continue
|
|
rel = os.path.relpath(abs_path, vault)
|
|
excerpt = text[:_EXCERPT_CHARS].strip()
|
|
hit = {
|
|
"path": rel,
|
|
"title": _obsidian_title(text, filename),
|
|
"excerpt": excerpt,
|
|
"score": score,
|
|
"source": "obsidian",
|
|
}
|
|
scored.append((score, _enrich_hit_vessel(hit, vessel_name=vessel_name)))
|
|
|
|
scored.sort(key=lambda x: (-x[0], x[1]["path"]))
|
|
return [hit for _, hit in scored[:_MAX_OBSIDIAN_HITS]]
|
|
|
|
|
|
async def gather_investigation_context(
|
|
title: str,
|
|
issue: str,
|
|
*,
|
|
vessel_name: str | None = None,
|
|
vessel_id: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Search memory MCP + Obsidian vault for prior related investigations."""
|
|
keywords = extract_search_keywords(f"{title} {issue}")
|
|
query_parts = list(keywords)
|
|
if vessel_name:
|
|
query_parts.insert(0, vessel_name)
|
|
query = " ".join(query_parts) if query_parts else f"{title} {issue}"[:200]
|
|
|
|
project_ids = [
|
|
p.strip()
|
|
for p in settings.memory_search_projects.split(",")
|
|
if p.strip()
|
|
]
|
|
if settings.memory_task_project_id not in project_ids:
|
|
project_ids.insert(0, settings.memory_task_project_id)
|
|
|
|
memory_hits = await _search_memory_projects(query, project_ids)
|
|
|
|
# If keyword search is sparse, add recent task memories for continuity.
|
|
if len(memory_hits) < 3:
|
|
recent = await _recent_memory(project_ids)
|
|
seen = {h["title"].lower() for h in memory_hits if h.get("title")}
|
|
for hit in recent:
|
|
t = hit.get("title", "").lower()
|
|
if t and t not in seen:
|
|
memory_hits.append(hit)
|
|
seen.add(t)
|
|
if len(memory_hits) >= _MAX_MEMORY_HITS:
|
|
break
|
|
|
|
obsidian_hits = await search_obsidian_notes(title, issue, vessel_name=vessel_name)
|
|
|
|
return {
|
|
"query": query,
|
|
"keywords": keywords,
|
|
"vessel_name": vessel_name,
|
|
"vessel_id": vessel_id,
|
|
"memory_hits": memory_hits[:_MAX_MEMORY_HITS],
|
|
"obsidian_hits": obsidian_hits,
|
|
"memory_count": len(memory_hits),
|
|
"obsidian_count": len(obsidian_hits),
|
|
}
|