| """ |
| main.py — FastAPI backend for the internal portal chat app. |
| |
| Runs on a Hugging Face Space (Docker, port 7860). CORS is open so the Vercel |
| frontend can call it. It also serves the static frontend at "/" when a frontend |
| directory is present, so the backend works standalone (and for local dev) too. |
| |
| API: |
| GET /api/health |
| GET /api/state domains (+projects) + HR doc list |
| POST /api/domains {name, description?, system_prompt?} |
| DELETE /api/domains/{did} |
| POST /api/domains/{did}/projects {name, description?} |
| DELETE /api/domains/{did}/projects/{pid} |
| GET /api/domains/{did}/projects/{pid} full project incl. milestones |
| POST /api/domains/{did}/projects/{pid}/import multipart file=<xlsx> |
| GET /api/hr/documents |
| POST /api/hr/documents multipart file=<pdf/docx/txt> |
| DELETE /api/hr/documents/{doc_id} |
| POST /api/chat {domain_id?, project_id?, hr?, messages} |
| -> streaming text/plain |
| """ |
| import asyncio |
| import datetime |
| import json |
| import os |
|
|
| from fastapi import FastAPI, File, HTTPException, Request, UploadFile |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import JSONResponse, StreamingResponse |
| from fastapi.staticfiles import StaticFiles |
| from pydantic import BaseModel |
|
|
| from . import agents, analytics, importer, llm, sheets, store |
|
|
| |
| _latest_digests = {"generated_at": None, "items": []} |
| DIGEST_INTERVAL_SECONDS = int(os.environ.get("DIGEST_INTERVAL_SECONDS", str(3 * 24 * 3600))) |
| |
| SYNC_INTERVAL_SECONDS = int(os.environ.get("SYNC_INTERVAL_SECONDS", "600")) |
| SEED_FILE = os.environ.get("SEED_FILE", os.path.join(os.path.dirname(__file__), "..", "seed.json")) |
|
|
|
|
| def _now() -> str: |
| return datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z" |
|
|
| app = FastAPI(title="Internal Portal API") |
|
|
| |
| |
| |
| |
| APP_PASSWORD = os.environ.get("APP_PASSWORD", "").strip() |
|
|
|
|
| @app.middleware("http") |
| async def _password_gate(request: Request, call_next): |
| if (APP_PASSWORD |
| and request.method != "OPTIONS" |
| and request.url.path.startswith("/api/") |
| and request.url.path != "/api/health"): |
| if request.headers.get("x-app-password", "") != APP_PASSWORD: |
| return JSONResponse({"detail": "unauthorized"}, status_code=401) |
| return await call_next(request) |
|
|
|
|
| |
| origins_env = os.environ.get("ALLOWED_ORIGINS", "*") |
| allow_origins = ["*"] if origins_env.strip() == "*" else [o.strip() for o in origins_env.split(",")] |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=allow_origins, |
| allow_credentials=False, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
|
|
| |
|
|
| class DomainIn(BaseModel): |
| name: str |
| description: str = "" |
| system_prompt: str = "" |
|
|
|
|
| class ProjectIn(BaseModel): |
| name: str |
| description: str = "" |
| sheet_url: str = "" |
|
|
|
|
| class LinkSheetIn(BaseModel): |
| url: str |
|
|
|
|
| class ChatTurn(BaseModel): |
| role: str |
| content: str |
|
|
|
|
| class ChatIn(BaseModel): |
| domain_id: str | None = None |
| project_id: str | None = None |
| hr: bool = False |
| messages: list[ChatTurn] = [] |
|
|
|
|
| class SummaryIn(BaseModel): |
| domain_id: str |
| project_id: str | None = None |
| narrative: bool = True |
|
|
|
|
| |
|
|
| @app.get("/api/health") |
| def health(): |
| return {"ok": True, "llm_configured": llm.is_configured(), "model": llm.MODEL, |
| "auth_required": bool(APP_PASSWORD)} |
|
|
|
|
| @app.get("/api/state") |
| def get_state(): |
| domains = [ |
| { |
| "id": d["id"], |
| "name": d["name"], |
| "description": d.get("description", ""), |
| "projects": [ |
| {"id": p["id"], "name": p["name"], "description": p.get("description", ""), |
| "milestone_count": len(p.get("milestones") or []), |
| "sheet_url": p.get("sheet_url", ""), "last_synced": p.get("last_synced")} |
| for p in d.get("projects", []) |
| ], |
| } |
| for d in store.list_domains() |
| ] |
| hr_docs = [{"id": doc["id"], "name": doc["name"]} for doc in store.list_hr_documents()] |
| return {"domains": domains, "hr_documents": hr_docs, "llm_configured": llm.is_configured()} |
|
|
|
|
| |
|
|
| @app.post("/api/domains") |
| def create_domain(body: DomainIn): |
| if not body.name.strip(): |
| raise HTTPException(400, "name is required") |
| return store.add_domain(body.name, body.description, body.system_prompt) |
|
|
|
|
| @app.delete("/api/domains/{did}") |
| def remove_domain(did: str): |
| if not store.delete_domain(did): |
| raise HTTPException(404, "domain not found") |
| return {"ok": True} |
|
|
|
|
| @app.post("/api/domains/{did}/projects") |
| async def create_project(did: str, body: ProjectIn): |
| if not store.get_domain(did): |
| raise HTTPException(404, "domain not found") |
| if not body.name.strip(): |
| raise HTTPException(400, "name is required") |
| p = store.add_project(did, body.name, body.description) |
| if body.sheet_url.strip(): |
| store.update_project(did, p["id"], sheet_url=body.sheet_url.strip()) |
| try: |
| await _sync_project(did, p["id"]) |
| except Exception as e: |
| raise HTTPException(400, f"project created but sheet sync failed: {e}") |
| p = store.get_project(did, p["id"]) |
| return p |
|
|
|
|
| @app.delete("/api/domains/{did}/projects/{pid}") |
| def remove_project(did: str, pid: str): |
| if not store.delete_project(did, pid): |
| raise HTTPException(404, "project not found") |
| return {"ok": True} |
|
|
|
|
| @app.get("/api/domains/{did}/projects/{pid}") |
| def project_detail(did: str, pid: str): |
| p = store.get_project(did, pid) |
| if not p: |
| raise HTTPException(404, "project not found") |
| return p |
|
|
|
|
| @app.get("/api/domains/{did}/projects/{pid}/snapshot") |
| def project_snapshot(did: str, pid: str): |
| p = store.get_project(did, pid) |
| if not p: |
| raise HTTPException(404, "project not found") |
| return analytics.snapshot_summary(p, analytics.today()) |
|
|
|
|
| @app.get("/api/domains/{did}/projects/{pid}/dashboard") |
| def project_dashboard(did: str, pid: str): |
| p = store.get_project(did, pid) |
| if not p: |
| raise HTTPException(404, "project not found") |
| return analytics.dashboard(p, analytics.today()) |
|
|
|
|
| @app.post("/api/domains/{did}/projects/{pid}/import") |
| async def import_milestones(did: str, pid: str, file: UploadFile = File(...)): |
| if not store.get_project(did, pid): |
| raise HTTPException(404, "project not found") |
| data = await file.read() |
| try: |
| milestones = importer.milestones_from_xlsx(data) |
| except Exception as e: |
| raise HTTPException(400, f"could not read spreadsheet: {e}") |
| store.set_project_milestones(did, pid, milestones) |
| store.update_project(did, pid, last_synced=_now()) |
| return {"ok": True, "milestone_count": len(milestones)} |
|
|
|
|
| async def _sync_project(did: str, pid: str) -> int: |
| """Pull the project's linked Google Sheet and replace its milestones.""" |
| p = store.get_project(did, pid) |
| if not p: |
| raise KeyError("project not found") |
| url = (p.get("sheet_url") or "").strip() |
| if not url: |
| raise RuntimeError("no Google Sheet linked to this project") |
| data = await sheets.fetch_xlsx(url) |
| milestones = importer.milestones_from_xlsx(data) |
| store.set_project_milestones(did, pid, milestones) |
| store.update_project(did, pid, last_synced=_now()) |
| return len(milestones) |
|
|
|
|
| @app.post("/api/domains/{did}/projects/{pid}/link-sheet") |
| async def link_sheet(did: str, pid: str, body: LinkSheetIn): |
| if not store.get_project(did, pid): |
| raise HTTPException(404, "project not found") |
| try: |
| sheets.extract_sheet_id(body.url) |
| except ValueError as e: |
| raise HTTPException(400, str(e)) |
| store.update_project(did, pid, sheet_url=body.url.strip()) |
| try: |
| count = await _sync_project(did, pid) |
| except Exception as e: |
| raise HTTPException(400, f"linked, but sync failed: {e}") |
| return {"ok": True, "milestone_count": count, "last_synced": store.get_project(did, pid)["last_synced"]} |
|
|
|
|
| @app.post("/api/domains/{did}/projects/{pid}/sync") |
| async def sync_now(did: str, pid: str): |
| if not store.get_project(did, pid): |
| raise HTTPException(404, "project not found") |
| try: |
| count = await _sync_project(did, pid) |
| except Exception as e: |
| raise HTTPException(400, str(e)) |
| return {"ok": True, "milestone_count": count, "last_synced": store.get_project(did, pid)["last_synced"]} |
|
|
|
|
| |
|
|
| @app.get("/api/hr/documents") |
| def hr_documents(): |
| return [{"id": d["id"], "name": d["name"]} for d in store.list_hr_documents()] |
|
|
|
|
| @app.post("/api/hr/documents") |
| async def upload_hr_document(file: UploadFile = File(...)): |
| data = await file.read() |
| try: |
| text = importer.extract_text(file.filename, data) |
| except Exception as e: |
| raise HTTPException(400, f"could not read document: {e}") |
| text = (text or "").strip() |
| if not text: |
| raise HTTPException(400, "no extractable text found in document") |
| chunks = agents.chunk_text(text) |
| doc = store.add_hr_document(file.filename, text, chunks) |
| return {"id": doc["id"], "name": doc["name"], "chunk_count": len(chunks)} |
|
|
|
|
| @app.delete("/api/hr/documents/{doc_id}") |
| def delete_hr_document(doc_id: str): |
| if not store.delete_hr_document(doc_id): |
| raise HTTPException(404, "document not found") |
| return {"ok": True} |
|
|
|
|
| |
|
|
| @app.post("/api/chat") |
| async def chat(body: ChatIn): |
| history = [t.model_dump() for t in body.messages] |
| if not history: |
| raise HTTPException(400, "messages is required") |
|
|
| if body.hr: |
| messages = agents.build_hr_messages(store.list_hr_documents(), history) |
| else: |
| domain = store.get_domain(body.domain_id) if body.domain_id else None |
| if not domain: |
| raise HTTPException(400, "domain_id is required (or set hr=true)") |
| project = None |
| if body.project_id: |
| project = store.get_project(domain["id"], body.project_id) |
| if not project: |
| raise HTTPException(404, "project not found") |
| messages = agents.build_domain_messages(domain, project, history) |
|
|
| async def gen(): |
| try: |
| async for piece in llm.stream_chat(messages): |
| yield piece |
| except Exception as e: |
| yield f"\n\n[error] {e}" |
|
|
| return StreamingResponse(gen(), media_type="text/plain; charset=utf-8") |
|
|
|
|
| |
|
|
| def _project_digest(domain: dict, project: dict) -> str: |
| return analytics.status_digest_text(project, analytics.today()) |
|
|
|
|
| @app.post("/api/summary") |
| async def summary(body: SummaryIn): |
| """Grounded status summary (no guessing). Optionally adds an LLM narrative.""" |
| domain = store.get_domain(body.domain_id) |
| if not domain: |
| raise HTTPException(404, "domain not found") |
|
|
| if body.project_id: |
| project = store.get_project(domain["id"], body.project_id) |
| if not project: |
| raise HTTPException(404, "project not found") |
| digest = _project_digest(domain, project) |
| else: |
| projs = domain.get("projects") or [] |
| if not projs: |
| digest = f"Domain '{domain['name']}' has no projects yet." |
| else: |
| digest = "\n\n".join(_project_digest(domain, p) for p in projs) |
|
|
| result = {"digest": digest, "narrative": None} |
| if body.narrative and llm.is_configured(): |
| messages = [ |
| {"role": "system", "content": |
| "You write a short, executive status update STRICTLY from the figures " |
| "below. Do not add facts, dates, or names not present. 4-6 sentences: " |
| "overall progress, the most pressing overdue/blocked items, and the next " |
| "deadline. If something isn't in the data, omit it."}, |
| {"role": "user", "content": digest}, |
| ] |
| try: |
| result["narrative"] = await llm.complete_chat(messages) |
| except Exception as e: |
| result["narrative"] = f"[narrative unavailable: {e}]" |
| return result |
|
|
|
|
| @app.get("/api/digests") |
| def get_digests(): |
| """Latest output of the recurring (every-3-days) digest job.""" |
| return _latest_digests |
|
|
|
|
| def _compute_all_digests() -> dict: |
| ref = analytics.today() |
| items = [] |
| for d in store.list_domains(): |
| for p in d.get("projects", []): |
| items.append({ |
| "domain": d["name"], |
| "project": p["name"], |
| "digest": analytics.status_digest_text(p, ref), |
| }) |
| return {"generated_at": ref.isoformat(), "items": items} |
|
|
|
|
| async def _digest_loop(): |
| |
| while True: |
| try: |
| global _latest_digests |
| _latest_digests = _compute_all_digests() |
| |
| except Exception: |
| pass |
| await asyncio.sleep(DIGEST_INTERVAL_SECONDS) |
|
|
|
|
| async def _sync_all_linked() -> None: |
| for d in store.list_domains(): |
| for p in d.get("projects", []): |
| if (p.get("sheet_url") or "").strip(): |
| try: |
| await _sync_project(d["id"], p["id"]) |
| except Exception: |
| pass |
|
|
|
|
| async def _sync_loop(): |
| while True: |
| await asyncio.sleep(SYNC_INTERVAL_SECONDS) |
| await _sync_all_linked() |
|
|
|
|
| async def _seed_if_empty() -> None: |
| """On a fresh (e.g. just-restarted, ephemeral) store, recreate domains/projects |
| from seed.json and pull their sheets — so the deployed app is self-restoring.""" |
| if store.list_domains(): |
| return |
| try: |
| with open(SEED_FILE, "r", encoding="utf-8") as f: |
| seed = json.load(f) |
| except (FileNotFoundError, json.JSONDecodeError): |
| return |
| for dom in seed.get("domains", []): |
| d = store.add_domain(dom.get("name", "").strip(), |
| dom.get("description", ""), |
| dom.get("system_prompt", "")) |
| for proj in dom.get("projects", []): |
| p = store.add_project(d["id"], proj.get("name", "").strip(), proj.get("description", "")) |
| url = (proj.get("sheet_url") or "").strip() |
| if url: |
| store.update_project(d["id"], p["id"], sheet_url=url) |
| try: |
| await _sync_project(d["id"], p["id"]) |
| except Exception: |
| pass |
|
|
|
|
| @app.on_event("startup") |
| async def _start_scheduler(): |
| await _seed_if_empty() |
| await _sync_all_linked() |
| asyncio.create_task(_digest_loop()) |
| asyncio.create_task(_sync_loop()) |
|
|
|
|
| |
| |
| |
| _frontend_dir = os.environ.get( |
| "FRONTEND_DIR", |
| os.path.join(os.path.dirname(__file__), "..", "..", "frontend"), |
| ) |
| if os.path.isdir(_frontend_dir): |
| app.mount("/", StaticFiles(directory=_frontend_dir, html=True), name="frontend") |
|
|