Project Monitor agent: LLM-powered project status reports with wiki+Gitea integration
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
#!/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}")
|
||||
Reference in New Issue
Block a user