Files
Agentic-OS/backend/app/services/investigation_context.py
nearxos 6185b9b85a 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>
2026-06-14 22:11:07 +03:00

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),
}