Files
ai-agents/agents/project_monitor.py
Eric Jungbauer 043aa18f3f API Clients + structured JSON results: app-level tokens for Synap/WSIT integration
- New api_clients + api_client_scopes tables; tokens scoped per-instance
- Admin UI tab at /admin for token create/rotate/revoke/delete with one-time reveal
- Dual-auth dependency (user session OR Bearer app token) on trigger + runs endpoints
- /api/instances/{id}/trigger pre-creates a run and returns run_id + cached last_result instantly
- New GET /api/runs/{id} for polling
- Generic trigger path for sub-agent instances (weather, calendar, etc.)
- runs.result column for structured JSON alongside markdown output
- agent_catalog.result_schema describes each agent's result shape
- Weather, daily-briefing, project-monitor retrofitted to emit structured results
- log_run: env INSTANCE_ID/RUN_ID only used when target matches, so nested sub-agents don't clobber parent runs
- Wiki docs: API Clients & Token Scoping + Calling Agents From Your Apps
2026-04-20 17:54:32 +00:00

422 lines
15 KiB
Python

#!/usr/bin/env python3
"""
Project Monitor Agent
Reads project data from wiki, Gitea, and custom URLs.
Uses LLM to generate intelligent status reports with analysis and next steps.
Posts full report to wiki, returns summary for daily briefing.
"""
import json
import sys
from datetime import datetime
from urllib import request as urlreq, error as urlerror
from shared import (
MT, DASHBOARD_API, WIKI_API, WIKI_TOKEN,
api_request, log_run, wiki_headers,
)
from llm_client import complete as llm_complete
AGENT_ID = "project-monitor"
GITEA_API = "http://192.168.1.204:3000/api/v1"
GITEA_TOKEN = "a03bb836c58010c4de35ac9c1f242292108c9776"
SYSTEM_PROMPT = """You are a project analyst for a home lab and software development environment.
You review project documentation, code activity, and issues to produce clear, actionable status reports.
Be specific — reference actual document names, commit messages, and issue titles.
Be concise but thorough. Prioritize what matters most."""
ANALYSIS_PROMPT = """Review the current state of this project and generate a status report.
Project: {project_name}
{custom_notes}
=== PROJECT DOCUMENTATION ===
{wiki_content}
=== CODE ACTIVITY (last 7 days) ===
{gitea_content}
=== ADDITIONAL CONTEXT ===
{url_content}
Generate a report with these sections:
## Status Summary
2-3 sentences on overall health, momentum, and any blockers.
## Recent Activity
What changed recently — commits, doc updates, new issues.
## Open Issues / Blockers
Anything stalled, broken, or needing attention.
## Recommended Next Steps
3-5 prioritized, actionable items.
## Ideas & Opportunities
Things to consider — improvements, risks, opportunities.
"""
def parse_wiki_id(raw_id):
"""Extract a wiki doc/collection ID from a UUID, URL slug, or full URL.
Accepts:
ae11e785-b110-4a86-985d-804f35bf3d7c (UUID)
bVLzs4hbbS (URL slug)
https://wiki.jfamily.io/doc/title-bVLzs4hbbS (full URL)
https://wiki.jfamily.io/collection/name-bVLzs4hbbS (full collection URL)
"""
raw_id = raw_id.strip()
if "/" in raw_id:
# Full URL — grab the last segment after the last dash
last_segment = raw_id.rstrip("/").split("/")[-1]
# URL format is "title-slug" — the slug is after the last dash
if "-" in last_segment:
return last_segment.split("-")[-1]
return last_segment
return raw_id
def fetch_wiki_docs(collection_id=None, doc_ids=None):
"""Fetch wiki documents recursively. Returns combined text content."""
headers = wiki_headers()
texts = []
if doc_ids:
for raw_id in doc_ids:
doc_id = parse_wiki_id(raw_id)
if not doc_id:
continue
try:
result = api_request(
f"{WIKI_API}/documents.info",
data={"id": doc_id},
headers=headers,
method="POST",
)
doc = result.get("data", {})
texts.append(f"### {doc.get('title', 'Untitled')}\n{doc.get('text', '')[:3000]}")
except Exception as e:
texts.append(f"### [Error fetching doc {doc_id}: {e}]")
elif collection_id:
collection_id = parse_wiki_id(collection_id)
try:
result = api_request(
f"{WIKI_API}/documents.list",
data={"collectionId": collection_id, "limit": 50},
headers=headers,
method="POST",
)
for doc in result.get("data", [])[:25]:
try:
full = api_request(
f"{WIKI_API}/documents.info",
data={"id": doc["id"]},
headers=headers,
method="POST",
)
d = full.get("data", {})
texts.append(f"### {d.get('title', 'Untitled')}\n{d.get('text', '')[:2000]}")
except Exception:
texts.append(f"### {doc.get('title', 'Untitled')}\n[Content not available]")
except Exception as e:
texts.append(f"[Error fetching collection: {e}]")
return "\n\n".join(texts) if texts else "No wiki documentation configured."
def fetch_gitea_activity(repo):
"""Fetch recent commits and open issues from a Gitea repo."""
if not repo:
return "No Gitea repo configured."
parts = []
headers = {"Authorization": f"token {GITEA_TOKEN}"}
# Recent commits
try:
url = f"{GITEA_API}/repos/{repo}/commits?limit=15&token={GITEA_TOKEN}"
req = urlreq.Request(url)
with urlreq.urlopen(req, timeout=15) as resp:
commits = json.loads(resp.read().decode())
if commits:
parts.append("Recent commits:")
for c in commits[:10]:
msg = c.get("commit", {}).get("message", "").split("\n")[0][:80]
date = c.get("commit", {}).get("author", {}).get("date", "")[:10]
parts.append(f" - [{date}] {msg}")
except Exception as e:
parts.append(f"[Could not fetch commits: {e}]")
# Open issues
try:
url = f"{GITEA_API}/repos/{repo}/issues?state=open&limit=20&token={GITEA_TOKEN}"
req = urlreq.Request(url)
with urlreq.urlopen(req, timeout=15) as resp:
issues = json.loads(resp.read().decode())
if issues:
parts.append(f"\nOpen issues ({len(issues)}):")
for i in issues[:10]:
labels = ", ".join(l.get("name", "") for l in i.get("labels", []))
parts.append(f" - #{i['number']}: {i['title']}" + (f" [{labels}]" if labels else ""))
except Exception as e:
parts.append(f"[Could not fetch issues: {e}]")
return "\n".join(parts) if parts else "No recent Gitea activity."
def fetch_url_content(urls_text):
"""Fetch text content from custom URLs."""
if not urls_text:
return "No additional URLs configured."
urls = [u.strip() for u in urls_text.strip().split("\n") if u.strip()]
if not urls:
return "No additional URLs configured."
parts = []
for url in urls[:5]:
try:
req = urlreq.Request(url, headers={"User-Agent": "AgentCommandCenter/1.0"})
with urlreq.urlopen(req, timeout=15) as resp:
content = resp.read().decode("utf-8", errors="replace")[:2000]
parts.append(f"### {url}\n{content}")
except Exception as e:
parts.append(f"### {url}\n[Error: {e}]")
return "\n\n".join(parts)
def post_report_to_wiki(report_md, project_name, collection_id):
"""Post the full report to a wiki collection under 'Project Status Reports' parent."""
if not collection_id:
return None
now = datetime.now(MT)
headers = wiki_headers()
# Find or create "Project Status Reports" parent doc at root of this collection
reports_parent_id = None
try:
search = api_request(
f"{WIKI_API}/documents.search",
data={"query": "Project Status Reports", "collectionId": collection_id},
headers=headers, method="POST",
)
for doc in search.get("data", []):
d = doc.get("document", {})
if d.get("title") == "Project Status Reports" and not d.get("parentDocumentId"):
reports_parent_id = d["id"]
break
except Exception:
pass
if not reports_parent_id:
try:
result = api_request(
f"{WIKI_API}/documents.create",
data={"title": "Project Status Reports",
"text": "Automated project status reports generated by the Project Monitor agent.",
"collectionId": collection_id, "publish": True},
headers=headers, method="POST",
)
reports_parent_id = result["data"]["id"]
print(f" Created Project Status Reports parent: {reports_parent_id}")
except Exception as e:
print(f" Warning: could not create reports parent: {e}", file=sys.stderr)
title = f"Project Status — {project_name}{now.strftime('%Y-%m-%d')}"
# Check for existing report today
try:
search = api_request(
f"{WIKI_API}/documents.search",
data={"query": title, "collectionId": collection_id},
headers=headers, method="POST",
)
for doc in search.get("data", []):
if doc.get("document", {}).get("title") == title:
api_request(
f"{WIKI_API}/documents.update",
data={"id": doc["document"]["id"], "text": report_md, "publish": True},
headers=headers, method="POST",
)
return doc["document"]["id"]
except Exception:
pass
# Create new under the reports parent
try:
create_data = {"title": title, "text": report_md, "collectionId": collection_id, "publish": True}
if reports_parent_id:
create_data["parentDocumentId"] = reports_parent_id
result = api_request(
f"{WIKI_API}/documents.create",
data=create_data, headers=headers, method="POST",
)
return result["data"]["id"]
except Exception as e:
print(f" Warning: could not post report to wiki: {e}", file=sys.stderr)
return None
def run(config, user_id=None, instance_id=None):
"""Run the project monitor agent.
Config keys:
project_name (str): Display name
wiki_collection_id (str): Outline collection to read
wiki_doc_ids (str): Comma-separated specific doc IDs
gitea_repo (str): owner/repo
custom_urls (str): Newline-separated URLs
custom_notes (str): Free text context
report_collection_id (str): Where to post the full report
include_in_briefing (str): "true"/"false"
Returns:
(markdown_section, summary) for daily briefing integration
"""
project_name = config.get("project_name", "Unknown Project")
if not user_id:
md = f"## {project_name}\n\n*No user context for LLM.*\n"
return {"markdown": md, "summary": "error: no user_id", "result": None}
print(f" Collecting data for {project_name}...")
# Collect data
wiki_collection = config.get("wiki_collection_id", "")
wiki_docs = config.get("wiki_doc_ids", "")
doc_ids = [d.strip() for d in wiki_docs.split(",") if d.strip()] if wiki_docs else None
wiki_content = fetch_wiki_docs(
collection_id=wiki_collection if not doc_ids else None,
doc_ids=doc_ids,
)
gitea_content = fetch_gitea_activity(config.get("gitea_repo", ""))
url_content = fetch_url_content(config.get("custom_urls", ""))
custom_notes = config.get("custom_notes", "")
if custom_notes:
custom_notes = f"Project context/goals:\n{custom_notes}"
# Build prompt
prompt = ANALYSIS_PROMPT.format(
project_name=project_name,
custom_notes=custom_notes,
wiki_content=wiki_content[:8000],
gitea_content=gitea_content[:3000],
url_content=url_content[:3000],
)
print(f" Calling LLM for analysis...")
try:
llm_result = llm_complete(user_id, prompt, system=SYSTEM_PROMPT, max_tokens=2000)
except RuntimeError as e:
err = str(e)
log_run(AGENT_ID, "failed", err=err, instance_id=instance_id)
md = f"## {project_name}\n\n*LLM error: {err}*\n"
return {"markdown": md, "summary": f"error: {err}", "result": None}
report_md = llm_result["text"]
model = llm_result["model"]
tokens_in = llm_result["input_tokens"]
tokens_out = llm_result["output_tokens"]
print(f" LLM: {model}, {tokens_in}+{tokens_out} tokens")
# Build quick links
app_url = config.get("app_url", "")
gitea_repo = config.get("gitea_repo", "")
wiki_collection = config.get("wiki_collection_id", "")
links = []
if app_url:
links.append(f"[Live App]({app_url})")
if wiki_collection:
links.append(f"[Wiki](https://wiki.jfamily.io/collection/{wiki_collection})")
if gitea_repo:
links.append(f"[Gitea](http://192.168.1.204:3000/{gitea_repo})")
links_md = " | ".join(links) if links else ""
# Post full report to wiki
report_collection = config.get("report_collection_id", "") or wiki_collection
doc_id = None
if report_collection:
now = datetime.now(MT)
full_report = f"# Project Status — {project_name}\n\n"
full_report += f"**{now.strftime('%A, %B %d, %Y')}** | Generated by Project Monitor Agent\n\n"
if links_md:
full_report += f"{links_md}\n\n"
full_report += f"---\n\n{report_md}\n\n"
full_report += f"---\n*Model: {model} | Tokens: {tokens_in} in, {tokens_out} out*\n"
doc_id = post_report_to_wiki(full_report, project_name, report_collection)
if doc_id:
print(f" Wiki report posted: {doc_id}")
# Extract just the status summary for the briefing
summary_lines = []
in_summary = False
for line in report_md.split("\n"):
if "## Status Summary" in line:
in_summary = True
continue
if in_summary and line.startswith("## "):
break
if in_summary and line.strip():
summary_lines.append(line.strip())
summary = " ".join(summary_lines)[:200] if summary_lines else report_md[:200]
# Briefing section with links
section = f"### {project_name}\n\n{summary}\n"
if links_md:
section += f"\n{links_md}\n"
structured = {
"project_name": project_name,
"app_url": app_url or None,
"wiki_collection_id": wiki_collection or None,
"wiki_report_id": doc_id,
"gitea_repo": gitea_repo or None,
"summary": summary,
"report_markdown": report_md,
"model": model,
"tokens": {"input": tokens_in, "output": tokens_out},
"generated_at": datetime.now(MT).isoformat(),
}
log_run(AGENT_ID, "success", output=f"{project_name}: {summary[:100]}",
instance_id=instance_id,
result=structured,
metadata={
"project": project_name,
"model": model,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"wiki_report_id": doc_id,
})
return {"markdown": section, "summary": summary, "result": structured}
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--user-id", type=int, required=True)
parser.add_argument("--instance-id", type=int, default=0)
parser.add_argument("--project-name", required=True)
parser.add_argument("--wiki-collection", default="")
parser.add_argument("--gitea-repo", default="")
args = parser.parse_args()
config = {
"project_name": args.project_name,
"wiki_collection_id": args.wiki_collection,
"gitea_repo": args.gitea_repo,
}
out = run(config, user_id=args.user_id, instance_id=args.instance_id)
print(out["markdown"])
print(f"\nSummary: {out['summary']}")