Files
ai-agents/agents/project_monitor.py
T

337 lines
12 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 fetch_wiki_docs(collection_id=None, doc_ids=None):
"""Fetch wiki documents. Returns combined text content."""
headers = wiki_headers()
texts = []
if doc_ids:
for doc_id in doc_ids:
doc_id = doc_id.strip()
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:
try:
result = api_request(
f"{WIKI_API}/documents.list",
data={"collectionId": collection_id, "limit": 25},
headers=headers,
method="POST",
)
for doc in result.get("data", [])[:15]:
# Fetch full content for each (up to limit)
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."""
if not collection_id:
return None
now = datetime.now(MT)
title = f"Project Status — {project_name}{now.strftime('%Y-%m-%d')}"
headers = wiki_headers()
# 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
try:
result = api_request(
f"{WIKI_API}/documents.create",
data={"title": title, "text": report_md, "collectionId": collection_id, "publish": True},
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:
return f"## {project_name}\n\n*No user context for LLM.*\n", "error: no user_id"
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:
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)
return f"## {project_name}\n\n*LLM error: {err}*\n", f"error: {err}"
report_md = result["text"]
model = result["model"]
tokens_in = result["input_tokens"]
tokens_out = result["output_tokens"]
print(f" LLM: {model}, {tokens_in}+{tokens_out} tokens")
# Post full report to wiki
report_collection = config.get("report_collection_id", "") or config.get("wiki_collection_id", "")
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"
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
section = f"### {project_name}\n\n{summary}\n"
log_run(AGENT_ID, "success", output=f"{project_name}: {summary[:100]}", instance_id=instance_id, metadata={
"project": project_name,
"model": model,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"wiki_report_id": doc_id,
})
return section, summary
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,
}
section, summary = run(config, user_id=args.user_id, instance_id=args.instance_id)
print(section)
print(f"\nSummary: {summary}")