""" 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= GET /api/hr/documents POST /api/hr/documents multipart file= 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 # Cached output of the recurring (every-3-days) status digest job. _latest_digests = {"generated_at": None, "items": []} DIGEST_INTERVAL_SECONDS = int(os.environ.get("DIGEST_INTERVAL_SECONDS", str(3 * 24 * 3600))) # How often linked Google Sheets are re-pulled so data stays current. 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") # Optional shared-password gate. If APP_PASSWORD is set, every /api/* request # (except health + CORS preflight) must send a matching X-App-Password header. # Registered BEFORE CORS so the CORS middleware stays outermost and adds headers # to the 401 too (otherwise the browser can't read the rejection). 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) # CORS: allow the Vercel frontend (and local dev). Tighten ALLOWED_ORIGINS in prod. 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=["*"], ) # ---- request models ---------------------------------------------------------- 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 # add a short LLM narrative on top of the grounded digest # ---- meta -------------------------------------------------------------------- @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()} # ---- domains & projects ------------------------------------------------------ @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: # noqa: BLE001 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: # noqa: BLE001 - surface a readable error to the UI 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) # validate it looks like a sheet URL/id 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: # noqa: BLE001 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: # noqa: BLE001 raise HTTPException(400, str(e)) return {"ok": True, "milestone_count": count, "last_synced": store.get_project(did, pid)["last_synced"]} # ---- HR documents ------------------------------------------------------------ @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: # noqa: BLE001 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} # ---- chat -------------------------------------------------------------------- @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: # noqa: BLE001 - stream a readable error to the UI yield f"\n\n[error] {e}" return StreamingResponse(gen(), media_type="text/plain; charset=utf-8") # ---- status summary + recurring digest --------------------------------------- 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: # noqa: BLE001 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(): # Compute once at startup, then every DIGEST_INTERVAL_SECONDS (default 3 days). while True: try: global _latest_digests _latest_digests = _compute_all_digests() # Delivery hook: email/Slack/etc. plugs in here when a channel is chosen. except Exception: # noqa: BLE001 - never let the loop die 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: # noqa: BLE001 - keep going on per-project errors 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: # noqa: BLE001 pass @app.on_event("startup") async def _start_scheduler(): await _seed_if_empty() await _sync_all_linked() # ensure data is fresh right after boot asyncio.create_task(_digest_loop()) asyncio.create_task(_sync_loop()) # ---- static frontend (optional convenience) ---------------------------------- # If a built frontend is present next to the backend, serve it at "/". On Vercel # the frontend is deployed separately, so this is just for standalone/local use. _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")