Initial commit: Agentic OS troubleshooting platform
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>
This commit is contained in:
317
backend/app/services/investigation_context.py
Normal file
317
backend/app/services/investigation_context.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""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),
|
||||
}
|
||||
Reference in New Issue
Block a user