404 lines
14 KiB
Python
404 lines
14 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:
|
|
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")
|
|
|
|
# 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"
|
|
|
|
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}")
|