vct / app /main.py
vg15o2's picture
Deploy Vconnect API
c91457f
Raw
History Blame Contribute Delete
16.7 kB
"""
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
# 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")