From b03b2d0ef9e20fefd8238046fecbd6c149a9550a Mon Sep 17 00:00:00 2001 From: Manpreet Singh <166604315+Code-311@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:38:47 +0530 Subject: [PATCH] Fix frontend API typing errors causing Next build failure (#5) --- .github/workflows/ci.yml | 40 ++++ HOWTO.md | 142 +++++++++++++ README.md | 89 +++------ .../alembic/versions/0003_company_signals.py | 37 ++++ .../alembic/versions/0004_recommendations.py | 46 +++++ backend/app/api/v1/routes.py | 78 +++++++- backend/app/db/base.py | 4 + backend/app/jobs/scheduler.py | 37 ++++ backend/app/models/company_signal.py | 18 ++ backend/app/models/recommendation.py | 22 ++ backend/app/schemas/company_signal.py | 16 ++ backend/app/schemas/recommendation.py | 21 ++ backend/app/services/company_intelligence.py | 161 +++++++++++++++ backend/app/services/decision_engine.py | 189 ++++++++++++++++++ .../app/tests/test_company_intelligence.py | 40 ++++ backend/app/tests/test_decision_engine.py | 92 +++++++++ backend/app/tests/test_recommendations_api.py | 32 +++ backend/pyproject.toml | 6 + frontend/src/app/admin/page.tsx | 56 +++++- frontend/src/app/dashboard/page.tsx | 30 ++- frontend/src/app/execution/page.tsx | 30 ++- frontend/src/app/network/page.tsx | 26 ++- frontend/src/app/opportunities/[id]/page.tsx | 3 + frontend/src/app/opportunities/page.tsx | 2 +- frontend/src/lib/api.ts | 2 +- 25 files changed, 1132 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 HOWTO.md create mode 100644 backend/alembic/versions/0003_company_signals.py create mode 100644 backend/alembic/versions/0004_recommendations.py create mode 100644 backend/app/models/company_signal.py create mode 100644 backend/app/models/recommendation.py create mode 100644 backend/app/schemas/company_signal.py create mode 100644 backend/app/schemas/recommendation.py create mode 100644 backend/app/services/company_intelligence.py create mode 100644 backend/app/services/decision_engine.py create mode 100644 backend/app/tests/test_company_intelligence.py create mode 100644 backend/app/tests/test_decision_engine.py create mode 100644 backend/app/tests/test_recommendations_api.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..df84105 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install backend dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[dev] + - name: Run backend tests + run: pytest -q + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install frontend dependencies + run: npm install + - name: Run frontend tests + run: npm test + - name: Build frontend + run: npm run build diff --git a/HOWTO.md b/HOWTO.md new file mode 100644 index 0000000..c736a4c --- /dev/null +++ b/HOWTO.md @@ -0,0 +1,142 @@ +# HOWTO: Run and Operate Career Radar MVP + +## 1) Prerequisites +- Docker + Docker Compose (recommended) +- Or local runtimes: + - Python 3.12+ + - Node.js 18+ + - PostgreSQL 16+ + +## 2) Environment variables and configuration +Backend settings are defined in `backend/app/core/config.py` and loaded from `.env`. + +Common values: +- `DATABASE_URL` (default: `postgresql+psycopg://postgres:postgres@db:5432/career_radar`) +- `USE_LLM_STRATEGY` (default `false`; MVP runs deterministic mode) +- `API_PREFIX` (default `/api/v1`) +- `CORS_ORIGINS` (default `*`) + +Frontend uses: +- `NEXT_PUBLIC_API_URL` (default `http://localhost:8000/api/v1`) + +When using Docker Compose, these are already wired in `docker-compose.yml`. + +## 3) Start with Docker Compose (recommended) +```bash +make up +``` +This starts: +- `db` (Postgres) +- `backend` (FastAPI) +- `frontend` (Next.js) + +URLs: +- Frontend: http://localhost:3000 +- Backend OpenAPI docs: http://localhost:8000/docs + +Stop: +```bash +make down +``` + +## 4) Run backend/frontend locally without Docker +### Backend +```bash +cd backend +pip install -e .[dev] +alembic upgrade head +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +### Frontend +```bash +cd frontend +npm install +NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 npm run dev +``` + +## 5) Migrations and seed data +- Apply migrations: +```bash +make migrate +``` +or `cd backend && alembic upgrade head`. + +- Seed data runs on backend startup in `app.main.startup_event()` via `app.db.seed.seed_data`. + +## 6) Scheduler behavior and cadence +Configured in `backend/app/jobs/scheduler.py`: +- ingest: every 30m +- rescore: every 20m +- strategy: every 60m +- stale check/signals: every 6h +- company intelligence: every 45m +- decision engine refresh: every 30m + +`job_runs` captures status, count, and summary for observability. + +## 7) Connector configuration and manual trigger +Opportunity connector ingest (mock connectors in MVP): +- `POST /api/v1/ingest/connectors` + +CSV ingest: +- `POST /api/v1/ingest/csv` + +Admin job trigger endpoint: +- `POST /api/v1/admin/jobs/{job_name}` +- supported: `ingest`, `rescore`, `strategy`, `stale`, `company_intelligence`, `decision_engine` + +## 8) Company intelligence trigger path +- Ingest endpoint: `POST /api/v1/ingest/company-intelligence` +- List signals: `GET /api/v1/company-signals` +- Company-specific signals: `GET /api/v1/companies/{company_id}/signals` + +## 9) Recommendation / decision engine behavior (high level) +- Refresh endpoint: `POST /api/v1/recommendations/refresh` +- List/filter endpoint: `GET /api/v1/recommendations?status=open&urgency=high` +- Detail endpoint: `GET /api/v1/recommendations/{id}` + +Decision inputs are deterministic: +- opportunity score +- opportunity signals (severity + recency) +- company intelligence signals +- network node quality +- staleness/timing + +Output categories: +- `OPPORTUNITY_PRIORITY` +- `SIGNAL_ALERT` +- `NETWORK_ACTION` +- `FOLLOW_UP_ACTION` +- `WATCHLIST_ESCALATION` + +Closed/archived/rejected opportunities are excluded from active recommendation generation. + +## 10) Where to inspect logs and job runs +- Runtime logs: +```bash +make logs +``` +- Job runs API: + - `GET /api/v1/admin/jobs/runs` +- Admin UI: + - `/admin` + +## 11) Basic troubleshooting +- API unavailable from frontend: + - verify `NEXT_PUBLIC_API_URL` + - verify backend is reachable at `http://localhost:8000` +- DB connection failures: + - check `DATABASE_URL` + - ensure Postgres is running and migration head is applied +- No recommendations shown: + - trigger `POST /api/v1/recommendations/refresh` + - check `GET /api/v1/recommendations?status=open` +- No realtime updates: + - check `GET /api/v1/events/stream` and browser network tab + +## 12) Known MVP limitations +- Auth/multi-user tenancy is not implemented. +- Connectors are intentionally minimal (MVP mock + RSS parsing path). +- Strategy path is deterministic by default. +- UI is functional and compact, not fully polished. diff --git a/README.md b/README.md index 28d6794..5f632c9 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,30 @@ -# Career Intelligence Agent + Career Radar Dashboard (MVP+) - -## What this build improves -This iteration hardens the original MVP with stronger explainable scoring, an actionable signal layer, scheduler observability, and more reliable SSE UX while preserving the same stack and local workflow. - -## Architecture -- **Backend**: FastAPI + SQLAlchemy + Alembic + APScheduler (`/backend`) -- **Frontend**: Next.js + TypeScript + Tailwind (`/frontend`) -- **DB**: PostgreSQL via Docker Compose -- **Realtime**: SSE (`/api/v1/events/stream`) with event ids + heartbeat + frontend reconnect state. -- **Adapters**: External job sources use connector adapters; strategy engine defaults deterministic and can switch via feature flag. - -## Core capabilities -- Structured profile engine with seeded senior ops/governance/security persona. -- Opportunity ingest (manual, CSV, mock connectors), CRUD, status updates. -- Explainable scoring engine with weighted factors: - - profile alignment - - compensation fit - - geography fit - - leadership/seniority fit - - industry fit - - strategic value - - ease of absorption -- Signal layer for opportunities/companies (`new_role_posted`, `comp_below_threshold`, `stale_opportunity`, `high_strategic_visibility`). -- Network intelligence + top entry-point recommendations. -- Weekly micro-actions + monthly strategy reviews. -- Background automation (ingest/rescore/strategy/stale checks). -- Job observability via persisted `job_runs` history and admin UI. - -## Scheduler behavior -APScheduler starts on backend startup and runs: -- ingest: every 30m -- rescore: every 20m -- strategy: every 60m -- stale check/signals: every 6h - -Each run writes `job_runs` with status, processed count, timestamps, and summary. You can inspect via: -- API: `GET /api/v1/admin/jobs/runs` -- UI: `/admin` -- Manual trigger: `POST /api/v1/admin/jobs/{ingest|rescore|strategy|stale}` - -## SSE behavior -SSE is used (instead of WebSockets) for simple one-way dashboard refreshes and lower operational complexity. -- Backend emits `id`, `data`, `retry`, and heartbeat comments. -- Frontend deduplicates events by version and reconnects automatically. -- UI shows realtime connection status badge. - -## Local run +# Career Radar (Locked MVP) + +Career Radar is a compact career-intelligence MVP that ingests opportunities, scores them, derives signals, and produces deterministic action recommendations. + +## Architecture (high level) +- **Backend:** FastAPI + SQLAlchemy + Alembic + APScheduler +- **Frontend:** Next.js + TypeScript +- **Database:** PostgreSQL +- **Realtime:** SSE (`/api/v1/events/stream`) +- **Data flows:** + - Opportunity ingest/connectors/CSV + - Scoring engine + - Opportunity signals + company intelligence signals + - Deterministic decision engine recommendations + - Scheduler + admin-triggered jobs + +## Quick start ```bash -cp .env.example .env +cp .env.example .env 2>/dev/null || true make up ``` +Then open: - Frontend: http://localhost:3000 - Backend docs: http://localhost:8000/docs -## Useful commands +Useful commands: ```bash make migrate make test-backend @@ -63,16 +33,11 @@ make logs make down ``` -## Seed data -On first backend startup, seed inserts: -- sample transition profile -- default scoring weights -- feature flags -- sample company + network node -- seeded opportunity (pre-scored) -- initial signals +## Operations guide +See **[HOWTO.md](./HOWTO.md)** for full local operation details, scheduler cadence, connector/recommendation behavior, troubleshooting, and current MVP limitations. + +## CI +GitHub Actions runs backend and frontend validation on push and pull request. -## Known limitations / next steps -- LLM strategy path currently reuses deterministic output when no provider integration is configured. -- Auth/multi-user tenancy not implemented in v1. -- Network view uses structured panel; graph visualization can be added later. +## MVP scope lock +This repo is intentionally locked to a compact MVP. New major capabilities should be captured as future work, not added in this stabilization pass. diff --git a/backend/alembic/versions/0003_company_signals.py b/backend/alembic/versions/0003_company_signals.py new file mode 100644 index 0000000..70d7ba7 --- /dev/null +++ b/backend/alembic/versions/0003_company_signals.py @@ -0,0 +1,37 @@ +"""company signals + +Revision ID: 0003_company_signals +Revises: 0002_signals_job_runs +Create Date: 2026-03-09 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0003_company_signals" +down_revision = "0002_signals_job_runs" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "company_signals", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("company_id", sa.Integer(), sa.ForeignKey("companies.id"), nullable=False), + sa.Column("signal_type", sa.String(40), nullable=False), + sa.Column("severity", sa.String(20), nullable=False), + sa.Column("title", sa.String(180), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("source_url", sa.String(500), nullable=False), + sa.Column("detected_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_company_signals_company_id", "company_signals", ["company_id"]) + op.create_index("ix_company_signals_signal_type", "company_signals", ["signal_type"]) + op.create_index("ix_company_signals_detected_at", "company_signals", ["detected_at"]) + + +def downgrade() -> None: + op.drop_index("ix_company_signals_detected_at", table_name="company_signals") + op.drop_index("ix_company_signals_signal_type", table_name="company_signals") + op.drop_index("ix_company_signals_company_id", table_name="company_signals") + op.drop_table("company_signals") diff --git a/backend/alembic/versions/0004_recommendations.py b/backend/alembic/versions/0004_recommendations.py new file mode 100644 index 0000000..7325b19 --- /dev/null +++ b/backend/alembic/versions/0004_recommendations.py @@ -0,0 +1,46 @@ +"""recommendations + +Revision ID: 0004_recommendations +Revises: 0003_company_signals +Create Date: 2026-03-09 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0004_recommendations" +down_revision = "0003_company_signals" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "recommendations", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("recommendation_type", sa.String(40), nullable=False), + sa.Column("entity_type", sa.String(30), nullable=False), + sa.Column("entity_id", sa.Integer(), nullable=False), + sa.Column("decision_score", sa.Float(), nullable=False), + sa.Column("urgency", sa.String(20), nullable=False), + sa.Column("confidence", sa.Float(), nullable=False), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("reason_summary", sa.Text(), nullable=False), + sa.Column("suggested_action", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("status", sa.String(20), nullable=False), + ) + op.create_index("ix_recommendations_recommendation_type", "recommendations", ["recommendation_type"]) + op.create_index("ix_recommendations_entity_type", "recommendations", ["entity_type"]) + op.create_index("ix_recommendations_entity_id", "recommendations", ["entity_id"]) + op.create_index("ix_recommendations_urgency", "recommendations", ["urgency"]) + op.create_index("ix_recommendations_status", "recommendations", ["status"]) + + +def downgrade() -> None: + op.drop_index("ix_recommendations_status", table_name="recommendations") + op.drop_index("ix_recommendations_urgency", table_name="recommendations") + op.drop_index("ix_recommendations_entity_id", table_name="recommendations") + op.drop_index("ix_recommendations_entity_type", table_name="recommendations") + op.drop_index("ix_recommendations_recommendation_type", table_name="recommendations") + op.drop_table("recommendations") diff --git a/backend/app/api/v1/routes.py b/backend/app/api/v1/routes.py index 6815a4d..17c5ef4 100644 --- a/backend/app/api/v1/routes.py +++ b/backend/app/api/v1/routes.py @@ -12,17 +12,23 @@ from app.models.config import ScoringWeight, FeatureFlag from app.models.signal import Signal from app.models.job import JobRun +from app.models.company_signal import CompanySignal +from app.models.recommendation import Recommendation from app.schemas.profile import ProfileBase, ProfileOut from app.schemas.opportunity import OpportunityIn, OpportunityOut from app.schemas.network import CompanyIn, CompanyOut, PersonNodeIn, PersonNodeOut from app.schemas.strategy import ActionPlanOut from app.schemas.signal import SignalOut, JobRunOut +from app.schemas.company_signal import CompanySignalOut +from app.schemas.recommendation import RecommendationOut from app.services.scoring import score_opportunity, get_weights from app.services.ingestion import CONNECTORS, parse_csv, persist_items from app.services.strategy import generate_plan +from app.services.company_intelligence import run_company_intelligence_connector from app.services.signals import generate_opportunity_signals +from app.services.decision_engine import refresh_recommendations from app.services.events import EventBus -from app.jobs.scheduler import ingest_job, rescore_job, strategy_job, stale_check_job +from app.jobs.scheduler import ingest_job, rescore_job, strategy_job, stale_check_job, company_intelligence_job, decision_engine_job router = APIRouter() @@ -134,6 +140,7 @@ def create_opportunity(payload: OpportunityIn, db: Session = Depends(get_db)): score_opportunity(db, opp, profile) db.commit() generate_opportunity_signals(db, profile) + refresh_recommendations(db) EventBus.bump("opportunity_created") return _opp_out(opp) @@ -145,6 +152,7 @@ def update_opp_status(opp_id: int, status: str, db: Session = Depends(get_db)): raise HTTPException(404, "Not found") opp.status = status db.commit() + refresh_recommendations(db) EventBus.bump("opportunity_status") return _opp_out(opp) @@ -168,8 +176,9 @@ def ingest_connectors(db: Session = Depends(get_db)): score_opportunity(db, opp, profile) db.commit() signal_count = generate_opportunity_signals(db, profile) + recommendation_count = refresh_recommendations(db) EventBus.bump("ingest_connectors") - return {"created": len(created), "signals": signal_count} + return {"created": len(created), "signals": signal_count, "recommendations": recommendation_count} @router.post("/ingest/csv") @@ -185,8 +194,9 @@ async def ingest_csv(file: UploadFile, db: Session = Depends(get_db)): score_opportunity(db, opp, profile) db.commit() signal_count = generate_opportunity_signals(db, profile) + recommendation_count = refresh_recommendations(db) EventBus.bump("ingest_csv") - return {"created": len(created), "signals": signal_count} + return {"created": len(created), "signals": signal_count, "recommendations": recommendation_count} @router.post("/rescore") @@ -200,8 +210,9 @@ def rescore(db: Session = Depends(get_db)): count += 1 db.commit() signal_count = generate_opportunity_signals(db, profile) + recommendation_count = refresh_recommendations(db) EventBus.bump("rescore") - return {"status": "done", "rescored": count, "signals": signal_count} + return {"status": "done", "rescored": count, "signals": signal_count, "recommendations": recommendation_count} @router.get("/signals", response_model=list[SignalOut]) @@ -214,6 +225,58 @@ def list_signals(signal_type: str | None = None, severity: str | None = None, db return q.order_by(Signal.created_at.desc()).all() +@router.get("/company-signals", response_model=list[CompanySignalOut]) +def list_company_signals(company_id: int | None = None, signal_type: str | None = None, db: Session = Depends(get_db)): + q = db.query(CompanySignal) + if company_id is not None: + q = q.filter(CompanySignal.company_id == company_id) + if signal_type: + q = q.filter(CompanySignal.signal_type == signal_type) + return q.order_by(CompanySignal.detected_at.desc()).all() + + +@router.post("/ingest/company-intelligence") +def ingest_company_intelligence(db: Session = Depends(get_db)): + created = run_company_intelligence_connector(db) + recommendation_count = refresh_recommendations(db) + EventBus.bump("company_intelligence_ingest") + return {"created": created, "recommendations": recommendation_count} + + + + +@router.get("/recommendations", response_model=list[RecommendationOut]) +def list_recommendations( + recommendation_type: str | None = None, + status: str | None = None, + urgency: str | None = None, + db: Session = Depends(get_db), +): + q = db.query(Recommendation) + if recommendation_type: + q = q.filter(Recommendation.recommendation_type == recommendation_type) + if status: + q = q.filter(Recommendation.status == status) + if urgency: + q = q.filter(Recommendation.urgency == urgency) + return q.order_by(Recommendation.decision_score.desc(), Recommendation.created_at.desc()).all() + + +@router.post("/recommendations/refresh") +def refresh_decisions(db: Session = Depends(get_db)): + created = refresh_recommendations(db) + EventBus.bump("recommendations_refresh") + return {"created": created} + + +@router.get("/recommendations/{recommendation_id}", response_model=RecommendationOut) +def recommendation_detail(recommendation_id: int, db: Session = Depends(get_db)): + rec = db.get(Recommendation, recommendation_id) + if not rec: + raise HTTPException(404, "Not found") + return rec + + @router.get("/companies", response_model=list[CompanyOut]) def companies(db: Session = Depends(get_db)): return db.query(Company).all() @@ -228,6 +291,11 @@ def create_company(payload: CompanyIn, db: Session = Depends(get_db)): return c +@router.get("/companies/{company_id}/signals", response_model=list[CompanySignalOut]) +def company_signals(company_id: int, db: Session = Depends(get_db)): + return db.query(CompanySignal).filter(CompanySignal.company_id == company_id).order_by(CompanySignal.detected_at.desc()).all() + + @router.get("/nodes", response_model=list[PersonNodeOut]) def nodes(db: Session = Depends(get_db)): return db.query(PersonNode).all() @@ -319,7 +387,7 @@ def job_runs(db: Session = Depends(get_db)): @router.post("/admin/jobs/{job_name}") def run_job(job_name: str): - jobs = {"ingest": ingest_job, "rescore": rescore_job, "strategy": strategy_job, "stale": stale_check_job} + jobs = {"ingest": ingest_job, "rescore": rescore_job, "strategy": strategy_job, "stale": stale_check_job, "company_intelligence": company_intelligence_job, "decision_engine": decision_engine_job} if job_name not in jobs: raise HTTPException(404, "unknown job") jobs[job_name]() diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 2dbc1ed..17a113c 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -5,7 +5,9 @@ from app.models.strategy import ActionPlanItem from app.models.config import ScoringWeight, FeatureFlag from app.models.signal import Signal +from app.models.company_signal import CompanySignal from app.models.job import JobRun +from app.models.recommendation import Recommendation __all__ = [ "Base", @@ -17,5 +19,7 @@ "ScoringWeight", "FeatureFlag", "Signal", + "CompanySignal", "JobRun", + "Recommendation", ] diff --git a/backend/app/jobs/scheduler.py b/backend/app/jobs/scheduler.py index a5295c7..9f78de3 100644 --- a/backend/app/jobs/scheduler.py +++ b/backend/app/jobs/scheduler.py @@ -7,11 +7,13 @@ from app.services.scoring import score_opportunity from app.services.strategy import generate_plan from app.services.signals import generate_opportunity_signals +from app.services.company_intelligence import run_company_intelligence_connector from app.models.opportunity import Opportunity from app.models.profile import UserProfile from app.models.job import JobRun from app.models.network import Company from app.services.events import EventBus +from app.services.decision_engine import refresh_recommendations logger = logging.getLogger(__name__) scheduler = BackgroundScheduler() @@ -122,6 +124,39 @@ def stale_check_job(): db.close() + + +def company_intelligence_job(): + db: Session = SessionLocal() + run = _record_job_start(db, "company_intelligence") + try: + created = run_company_intelligence_connector(db) + _record_job_end(db, run, "success", created, f"company_signals={created}") + EventBus.bump("company_intelligence") + except Exception as exc: + logger.exception("company_intelligence_job_failed") + _record_job_end(db, run, "failed", 0, str(exc)) + raise + finally: + db.close() + + + +def decision_engine_job(): + db: Session = SessionLocal() + run = _record_job_start(db, "decision_engine") + try: + created = refresh_recommendations(db) + _record_job_end(db, run, "success", created, f"recommendations={created}") + EventBus.bump("decision_engine") + except Exception as exc: + logger.exception("decision_engine_job_failed") + _record_job_end(db, run, "failed", 0, str(exc)) + raise + finally: + db.close() + + def start_scheduler(): if scheduler.running: return @@ -129,6 +164,8 @@ def start_scheduler(): scheduler.add_job(rescore_job, "interval", minutes=20, id="rescore", replace_existing=True) scheduler.add_job(strategy_job, "interval", minutes=60, id="strategy", replace_existing=True) scheduler.add_job(stale_check_job, "interval", hours=6, id="stale", replace_existing=True) + scheduler.add_job(company_intelligence_job, "interval", minutes=45, id="company_intelligence", replace_existing=True) + scheduler.add_job(decision_engine_job, "interval", minutes=30, id="decision_engine", replace_existing=True) scheduler.start() diff --git a/backend/app/models/company_signal.py b/backend/app/models/company_signal.py new file mode 100644 index 0000000..0f90282 --- /dev/null +++ b/backend/app/models/company_signal.py @@ -0,0 +1,18 @@ +from datetime import datetime +from sqlalchemy import String, DateTime, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from app.models.base import Base + + +class CompanySignal(Base): + __tablename__ = "company_signals" + + id: Mapped[int] = mapped_column(primary_key=True) + company_id: Mapped[int] = mapped_column(ForeignKey("companies.id"), index=True) + signal_type: Mapped[str] = mapped_column(String(40), index=True) + severity: Mapped[str] = mapped_column(String(20), default="info") + title: Mapped[str] = mapped_column(String(180)) + description: Mapped[str] = mapped_column(Text, default="") + source_url: Mapped[str] = mapped_column(String(500), default="") + detected_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + diff --git a/backend/app/models/recommendation.py b/backend/app/models/recommendation.py new file mode 100644 index 0000000..8c1bd7c --- /dev/null +++ b/backend/app/models/recommendation.py @@ -0,0 +1,22 @@ +from datetime import datetime +from sqlalchemy import String, DateTime, Text, Float, Integer +from sqlalchemy.orm import Mapped, mapped_column +from app.models.base import Base + + +class Recommendation(Base): + __tablename__ = "recommendations" + + id: Mapped[int] = mapped_column(primary_key=True) + recommendation_type: Mapped[str] = mapped_column(String(40), index=True) + entity_type: Mapped[str] = mapped_column(String(30), index=True) + entity_id: Mapped[int] = mapped_column(Integer, index=True) + decision_score: Mapped[float] = mapped_column(Float, default=0) + urgency: Mapped[str] = mapped_column(String(20), default="medium", index=True) + confidence: Mapped[float] = mapped_column(Float, default=0.5) + title: Mapped[str] = mapped_column(String(200)) + reason_summary: Mapped[str] = mapped_column(Text, default="") + suggested_action: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(20), default="open", index=True) diff --git a/backend/app/schemas/company_signal.py b/backend/app/schemas/company_signal.py new file mode 100644 index 0000000..b62179e --- /dev/null +++ b/backend/app/schemas/company_signal.py @@ -0,0 +1,16 @@ +from datetime import datetime +from pydantic import BaseModel + + +class CompanySignalOut(BaseModel): + id: int + company_id: int + signal_type: str + severity: str + title: str + description: str + source_url: str + detected_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/schemas/recommendation.py b/backend/app/schemas/recommendation.py new file mode 100644 index 0000000..4fcf64f --- /dev/null +++ b/backend/app/schemas/recommendation.py @@ -0,0 +1,21 @@ +from datetime import datetime +from pydantic import BaseModel + + +class RecommendationOut(BaseModel): + id: int + recommendation_type: str + entity_type: str + entity_id: int + decision_score: float + urgency: str + confidence: float + title: str + reason_summary: str + suggested_action: str + created_at: datetime + expires_at: datetime | None + status: str + + class Config: + from_attributes = True diff --git a/backend/app/services/company_intelligence.py b/backend/app/services/company_intelligence.py new file mode 100644 index 0000000..97e9189 --- /dev/null +++ b/backend/app/services/company_intelligence.py @@ -0,0 +1,161 @@ +from datetime import datetime +from xml.etree import ElementTree +from sqlalchemy.orm import Session +from app.models.network import Company +from app.models.opportunity import Opportunity +from app.models.signal import Signal +from app.models.company_signal import CompanySignal + +SIGNAL_TYPES = { + "EXPANSION", + "LEADERSHIP_CHANGE", + "CONTRACT", + "FUNDING", + "DIVISION_LAUNCH", +} + + +class CompanyIntelligenceConnector: + source = "company_intelligence_rss" + + def __init__(self, feeds: list[str] | None = None): + self.feeds = feeds or [] + + def ingest(self, payloads: list[str]) -> list[dict]: + events: list[dict] = [] + for payload in payloads: + events.extend(parse_rss_events(payload)) + return [normalize_company_event(e) for e in events if e.get("title")] + + +def parse_rss_events(payload: str) -> list[dict]: + root = ElementTree.fromstring(payload) + out = [] + for item in root.findall("./channel/item"): + out.append( + { + "title": (item.findtext("title") or "").strip(), + "description": (item.findtext("description") or "").strip(), + "source_url": (item.findtext("link") or "").strip(), + "detected_at": _parse_datetime(item.findtext("pubDate")), + } + ) + return out + + +def _parse_datetime(raw: str | None) -> datetime: + if not raw: + return datetime.utcnow() + for fmt in ("%a, %d %b %Y %H:%M:%S %z", "%Y-%m-%dT%H:%M:%SZ"): + try: + dt = datetime.strptime(raw, fmt) + return dt.replace(tzinfo=None) + except ValueError: + continue + return datetime.utcnow() + + +def _extract_company_name(title: str, description: str) -> str: + merged = f"{title} {description}".strip() + if ":" in merged: + merged = merged.split(":", 1)[0] + words = merged.split() + return " ".join(words[:3]) if words else "Unknown Co" + + +def classify_signal_type(title: str, description: str) -> str: + haystack = f"{title} {description}".lower() + if any(token in haystack for token in ["raises", "series", "funding", "investment"]): + return "FUNDING" + if any(token in haystack for token in ["appoints", "appointed", "ceo", "cfo", "chief"]): + return "LEADERSHIP_CHANGE" + if any(token in haystack for token in ["contract", "agreement", "deal", "awarded"]): + return "CONTRACT" + if any(token in haystack for token in ["launches", "launch", "division", "business unit"]): + return "DIVISION_LAUNCH" + if any(token in haystack for token in ["expand", "expands", "opens", "new office", "growth"]): + return "EXPANSION" + return "EXPANSION" + + +def normalize_company_event(event: dict) -> dict: + title = event.get("title", "") + description = event.get("description", "") + signal_type = classify_signal_type(title, description) + severity = "success" if signal_type in {"FUNDING", "CONTRACT"} else "info" + return { + "company": event.get("company") or _extract_company_name(title, description), + "signal_type": signal_type, + "severity": severity, + "title": title[:180], + "description": description, + "source_url": event.get("source_url", ""), + "detected_at": event.get("detected_at") or datetime.utcnow(), + } + + +def ingest_company_signals(db: Session, events: list[dict]) -> int: + created = 0 + for event in events: + normalized = normalize_company_event(event) + company = db.query(Company).filter(Company.name == normalized["company"]).first() + if not company: + company = Company(name=normalized["company"], industry="") + db.add(company) + db.flush() + exists = ( + db.query(CompanySignal) + .filter(CompanySignal.company_id == company.id) + .filter(CompanySignal.title == normalized["title"]) + .first() + ) + if exists: + continue + db.add( + CompanySignal( + company_id=company.id, + signal_type=normalized["signal_type"], + severity=normalized["severity"], + title=normalized["title"], + description=normalized["description"], + source_url=normalized["source_url"], + detected_at=normalized["detected_at"], + ) + ) + created += 1 + _create_opportunity_signal_for_company_event(db, company.id, normalized) + db.commit() + return created + + +def _create_opportunity_signal_for_company_event(db: Session, company_id: int, signal: dict) -> None: + related = ( + db.query(Opportunity) + .filter(Opportunity.company_id == company_id) + .filter(Opportunity.status.in_(["new", "applied"])) + .all() + ) + for opp in related: + db.add( + Signal( + signal_type=f"company_{signal['signal_type'].lower()}", + severity=signal["severity"], + title=f"Company event: {signal['title']}", + details=signal["description"] or "Company intelligence event detected.", + company_id=company_id, + opportunity_id=opp.id, + ) + ) + +SAMPLE_RSS_FEED = """ + +Contoso raises Series C fundingContoso announces new funding round to expand operations.https://news.example/contoso-fundingMon, 01 Jan 2024 10:00:00 +0000 +Fabrikam appoints new Chief Risk OfficerLeadership update as Fabrikam hires CRO.https://news.example/fabrikam-leadershipTue, 02 Jan 2024 11:00:00 +0000 + +""" + + +def run_company_intelligence_connector(db: Session, payloads: list[str] | None = None) -> int: + connector = CompanyIntelligenceConnector() + events = connector.ingest(payloads or [SAMPLE_RSS_FEED]) + return ingest_company_signals(db, events) diff --git a/backend/app/services/decision_engine.py b/backend/app/services/decision_engine.py new file mode 100644 index 0000000..a575948 --- /dev/null +++ b/backend/app/services/decision_engine.py @@ -0,0 +1,189 @@ +from datetime import datetime, timedelta +from sqlalchemy.orm import Session +from app.models.opportunity import Opportunity +from app.models.signal import Signal +from app.models.network import PersonNode +from app.models.company_signal import CompanySignal +from app.models.recommendation import Recommendation + +RECOMMENDATION_TYPES = { + "OPPORTUNITY_PRIORITY", + "SIGNAL_ALERT", + "NETWORK_ACTION", + "FOLLOW_UP_ACTION", + "WATCHLIST_ESCALATION", +} + + +def _severity_weight(severity: str) -> float: + return {"success": 1.0, "warning": 0.8, "info": 0.5}.get((severity or "").lower(), 0.4) + + +def _urgency_for(score: float) -> str: + if score >= 8.5: + return "high" + if score >= 6.5: + return "medium" + return "low" + + +def _reco_key(payload: dict) -> tuple[str, str, int]: + return (payload["recommendation_type"], payload["entity_type"], payload["entity_id"]) + + +def _upsert_recommendation(db: Session, payload: dict) -> int: + existing = ( + db.query(Recommendation) + .filter(Recommendation.recommendation_type == payload["recommendation_type"]) + .filter(Recommendation.entity_type == payload["entity_type"]) + .filter(Recommendation.entity_id == payload["entity_id"]) + .filter(Recommendation.status == "open") + .first() + ) + if existing: + for key, value in payload.items(): + setattr(existing, key, value) + existing.created_at = datetime.utcnow() + return 0 + db.add(Recommendation(**payload)) + return 1 + + +def _make_opportunity_reco(opp: Opportunity, signals: list[Signal], company_signals: list[CompanySignal]) -> dict: + recent_signal = sum(_severity_weight(s.severity) for s in signals if s.created_at >= datetime.utcnow() - timedelta(days=10)) + company_context = sum(_severity_weight(s.severity) for s in company_signals[:3]) + timing_penalty = 1.2 if opp.discovered_at < datetime.utcnow() - timedelta(days=21) else 0 + decision_score = max(0.0, (opp.score_total * 0.7) + recent_signal + company_context - timing_penalty) + confidence = min(0.95, 0.55 + (0.04 * len(signals)) + (0.03 * len(company_signals))) + return { + "recommendation_type": "OPPORTUNITY_PRIORITY", + "entity_type": "opportunity", + "entity_id": opp.id, + "decision_score": round(decision_score, 2), + "urgency": _urgency_for(decision_score), + "confidence": round(confidence, 2), + "title": f"Prioritize {opp.role_title} at {opp.company}", + "reason_summary": f"Opportunity score {opp.score_total:.1f}; recent signals {len(signals)}; company intelligence hits {len(company_signals)}.", + "suggested_action": "Advance to next milestone this week and initiate targeted outreach.", + "expires_at": datetime.utcnow() + timedelta(days=14), + "status": "open", + } + + +def _make_follow_up_reco(opp: Opportunity) -> dict | None: + if opp.status not in {"new", "applied"}: + return None + if opp.discovered_at >= datetime.utcnow() - timedelta(days=14): + return None + age_days = (datetime.utcnow() - opp.discovered_at).days + score = min(9.0, 6.5 + (age_days / 10)) + return { + "recommendation_type": "FOLLOW_UP_ACTION", + "entity_type": "opportunity", + "entity_id": opp.id, + "decision_score": round(score, 2), + "urgency": "high" if age_days > 21 else "medium", + "confidence": 0.82, + "title": f"Follow up on {opp.company} ({opp.role_title})", + "reason_summary": f"No status movement for {age_days} days while still {opp.status}.", + "suggested_action": "Send follow-up note to recruiter/hiring manager and update status notes.", + "expires_at": datetime.utcnow() + timedelta(days=7), + "status": "open", + } + + +def _make_network_reco(opp: Opportunity, nodes: list[PersonNode]) -> dict | None: + if not nodes: + return None + top = max(nodes, key=lambda n: n.influence_score + n.accessibility_score + n.relationship_strength) + network_score = top.influence_score + top.accessibility_score + top.relationship_strength + decision_score = min(9.5, (opp.score_total * 0.45) + (network_score * 0.55)) + return { + "recommendation_type": "NETWORK_ACTION", + "entity_type": "opportunity", + "entity_id": opp.id, + "decision_score": round(decision_score, 2), + "urgency": _urgency_for(decision_score), + "confidence": 0.76, + "title": f"Use network path for {opp.company}", + "reason_summary": f"Best connector is {top.full_name} ({top.node_role_type}) with combined network strength {network_score:.1f}.", + "suggested_action": f"Request intro through {top.full_name} and tailor outreach to {opp.role_title}.", + "expires_at": datetime.utcnow() + timedelta(days=10), + "status": "open", + } + + +def _make_company_watchlist_reco(cs: CompanySignal) -> dict: + score = 7.5 + _severity_weight(cs.severity) + return { + "recommendation_type": "WATCHLIST_ESCALATION", + "entity_type": "company", + "entity_id": cs.company_id, + "decision_score": round(score, 2), + "urgency": "high" if cs.signal_type in {"FUNDING", "CONTRACT"} else "medium", + "confidence": 0.72, + "title": f"Escalate watchlist: {cs.signal_type}", + "reason_summary": f"{cs.title} ({cs.signal_type}) detected from company intelligence feed.", + "suggested_action": "Review active roles and refresh target-company strategy.", + "expires_at": datetime.utcnow() + timedelta(days=21), + "status": "open", + } + + +def _make_signal_alert(signal: Signal) -> dict: + age_days = max(0, (datetime.utcnow() - signal.created_at).days) + score = max(4.5, 8.5 - (age_days * 0.2) + _severity_weight(signal.severity)) + return { + "recommendation_type": "SIGNAL_ALERT", + "entity_type": "opportunity" if signal.opportunity_id else "company", + "entity_id": signal.opportunity_id or signal.company_id or 0, + "decision_score": round(score, 2), + "urgency": _urgency_for(score), + "confidence": 0.67, + "title": f"Signal alert: {signal.title}", + "reason_summary": signal.details[:220], + "suggested_action": "Validate signal relevance and convert into a concrete next step.", + "expires_at": datetime.utcnow() + timedelta(days=10), + "status": "open", + } + + +def refresh_recommendations(db: Session) -> int: + generated: list[dict] = [] + + opportunities = db.query(Opportunity).all() + for opp in opportunities: + if opp.status in {"closed", "archived", "rejected"}: + continue + opp_signals = db.query(Signal).filter(Signal.opportunity_id == opp.id).order_by(Signal.created_at.desc()).limit(5).all() + intel = [] + if opp.company_id: + intel = db.query(CompanySignal).filter(CompanySignal.company_id == opp.company_id).order_by(CompanySignal.detected_at.desc()).limit(3).all() + nodes = db.query(PersonNode).filter(PersonNode.company_id == opp.company_id).all() if opp.company_id else [] + + generated.append(_make_opportunity_reco(opp, opp_signals, intel)) + follow_up = _make_follow_up_reco(opp) + if follow_up: + generated.append(follow_up) + network = _make_network_reco(opp, nodes) + if network: + generated.append(network) + + for cs in db.query(CompanySignal).order_by(CompanySignal.detected_at.desc()).limit(20).all(): + generated.append(_make_company_watchlist_reco(cs)) + for signal in db.query(Signal).order_by(Signal.created_at.desc()).limit(20).all(): + generated.append(_make_signal_alert(signal)) + + keys = {_reco_key(r) for r in generated} + stale = db.query(Recommendation).filter(Recommendation.status == "open").all() + for rec in stale: + rec_key = (rec.recommendation_type, rec.entity_type, rec.entity_id) + if rec_key not in keys: + rec.status = "expired" + + created = 0 + for payload in generated: + created += _upsert_recommendation(db, payload) + + db.commit() + return created diff --git a/backend/app/tests/test_company_intelligence.py b/backend/app/tests/test_company_intelligence.py new file mode 100644 index 0000000..0dabe81 --- /dev/null +++ b/backend/app/tests/test_company_intelligence.py @@ -0,0 +1,40 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.models.base import Base +from app.models.network import Company +from app.models.opportunity import Opportunity +from app.models.signal import Signal +from app.models.company_signal import CompanySignal +from app.services.company_intelligence import classify_signal_type, ingest_company_signals + + +def _db_session(): + engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all(engine) + return sessionmaker(bind=engine)() + + +def test_classify_signal_type_funding(): + assert classify_signal_type('Contoso raises Series C funding', 'Round led by investors') == 'FUNDING' + + +def test_ingest_company_signal_creates_company_and_opportunity_signal(): + db = _db_session() + company = Company(name='Contoso', industry='Tech') + db.add(company) + db.flush() + db.add(Opportunity(company='Contoso', role_title='Director', location='Remote', estimated_compensation=1, source='x', source_url='', description='', status='new', notes='', company_id=company.id)) + db.commit() + + created = ingest_company_signals(db, [{ + 'company': 'Contoso', + 'title': 'Contoso raises Series C funding', + 'description': 'Growth financing to scale internationally.', + 'source_url': 'https://news.example/funding', + }]) + + assert created == 1 + assert db.query(CompanySignal).count() == 1 + linked = db.query(Signal).all() + assert len(linked) == 1 + assert linked[0].signal_type == 'company_funding' diff --git a/backend/app/tests/test_decision_engine.py b/backend/app/tests/test_decision_engine.py new file mode 100644 index 0000000..5ab2f2e --- /dev/null +++ b/backend/app/tests/test_decision_engine.py @@ -0,0 +1,92 @@ +from datetime import datetime, timedelta +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.models.base import Base +from app.models.network import Company, PersonNode +from app.models.opportunity import Opportunity +from app.models.signal import Signal +from app.models.company_signal import CompanySignal +from app.models.recommendation import Recommendation +from app.services.decision_engine import refresh_recommendations + + +def _db_session(): + engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all(engine) + return sessionmaker(bind=engine)() + + +def test_refresh_recommendations_generates_ranked_actions(): + db = _db_session() + company = Company(name='Contoso', industry='Tech') + db.add(company) + db.flush() + opp = Opportunity( + company='Contoso', + role_title='Director Security', + location='Remote', + estimated_compensation=200000, + source='mock', + source_url='', + description='desc', + status='new', + notes='', + score_total=8.8, + company_id=company.id, + discovered_at=datetime.utcnow() - timedelta(days=25), + ) + db.add(opp) + db.flush() + db.add(PersonNode(full_name='Jane Doe', role_title='VP Ops', node_role_type='EXEC', influence_score=8.0, accessibility_score=7.5, relationship_strength=7.0, connection_path='', notes_history='', company_id=company.id, opportunity_id=opp.id)) + db.add(Signal(signal_type='stale_opportunity', severity='warning', title='Stale', details='old', company_id=company.id, opportunity_id=opp.id)) + db.add(CompanySignal(company_id=company.id, signal_type='FUNDING', severity='success', title='Contoso raises funding', description='funding', source_url='https://example.com', detected_at=datetime.utcnow())) + db.commit() + + created = refresh_recommendations(db) + + assert created >= 4 + recs = db.query(Recommendation).filter(Recommendation.status == 'open').all() + types = {r.recommendation_type for r in recs} + assert 'OPPORTUNITY_PRIORITY' in types + assert 'FOLLOW_UP_ACTION' in types + assert 'NETWORK_ACTION' in types + assert 'WATCHLIST_ESCALATION' in types + + +def test_refresh_recommendations_expires_closed_opportunity_items(): + db = _db_session() + company = Company(name='Northwind', industry='Tech') + db.add(company) + db.flush() + opp = Opportunity( + company='Northwind', + role_title='VP Security', + location='Remote', + estimated_compensation=220000, + source='mock', + source_url='', + description='desc', + status='new', + notes='', + score_total=9.0, + company_id=company.id, + discovered_at=datetime.utcnow() - timedelta(days=10), + ) + db.add(opp) + db.commit() + + refresh_recommendations(db) + assert db.query(Recommendation).filter(Recommendation.status == 'open').count() >= 1 + + opp.status = 'closed' + db.commit() + refresh_recommendations(db) + + open_for_opp = ( + db.query(Recommendation) + .filter(Recommendation.entity_type == 'opportunity') + .filter(Recommendation.entity_id == opp.id) + .filter(Recommendation.status == 'open') + .count() + ) + assert open_for_opp == 0 diff --git a/backend/app/tests/test_recommendations_api.py b/backend/app/tests/test_recommendations_api.py new file mode 100644 index 0000000..9c4b7cb --- /dev/null +++ b/backend/app/tests/test_recommendations_api.py @@ -0,0 +1,32 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.pool import StaticPool +from sqlalchemy.orm import sessionmaker +from app.api.v1.routes import router +from app.db.session import get_db +from app.models.base import Base + + +def test_recommendations_endpoints_exist(): + engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + Base.metadata.create_all(bind=engine) + + def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + app = FastAPI() + app.include_router(router, prefix='/api/v1') + app.dependency_overrides[get_db] = override_get_db + client = TestClient(app) + + resp = client.get('/api/v1/recommendations') + assert resp.status_code == 200 + + refresh = client.post('/api/v1/recommendations/refresh') + assert refresh.status_code == 200 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a1e4695..a5979fe 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -29,6 +29,12 @@ pythonpath = ["."] [tool.ruff] line-length = 100 + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] +exclude = ["alembic*"] + [build-system] requires = ["setuptools>=69", "wheel"] build-backend = "setuptools.build_meta" diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index e393603..93d28a4 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -2,16 +2,49 @@ import { api } from '@/lib/api'; import { useEffect, useState } from 'react'; +interface JobRun { + id: number; + job_name: string; + status: string; + processed_count: number; + summary: string; +} + +interface CompanySignal { + id: number; + company_id: number; + signal_type: string; + severity: string; + title: string; +} + +interface Recommendation { + id: number; + recommendation_type: string; + urgency: string; + decision_score: number; + title: string; +} + export default function AdminPage() { - const [runs, setRuns] = useState([]); + const [runs, setRuns] = useState([]); const [error, setError] = useState(''); + const [companySignals, setCompanySignals] = useState([]); + const [recommendations, setRecommendations] = useState([]); const load = async () => { try { setError(''); - setRuns(await api('/admin/jobs/runs')); - } catch (e: any) { - setError(e?.message || 'Failed to load job runs'); + const [r, cs, recs] = await Promise.all([ + api('/admin/jobs/runs'), + api('/company-signals'), + api('/recommendations?status=open'), + ]); + setRuns(r); + setCompanySignals(cs); + setRecommendations(recs); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to load job runs'); } }; @@ -21,17 +54,26 @@ export default function AdminPage() { setError(''); await api(`/admin/jobs/${j}`, { method: 'POST' }); await load(); - } catch (e: any) { - setError(e?.message || `Failed to run ${j}`); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : `Failed to run ${j}`); } }; return

Automation Jobs

{error &&
{error}
} -
{['ingest', 'rescore', 'strategy', 'stale'].map((j) => )}
+
{['ingest', 'rescore', 'strategy', 'stale', 'company_intelligence', 'decision_engine'].map((j) => )}

Recent Job Runs

{runs.map((r) => )}
JobStatusProcessedSummary
{r.job_name}{r.status}{r.processed_count}{r.summary}
+
+

Latest Company Signals

+ {companySignals.slice(0, 20).map((s) => )}
Company IDTypeSeverityTitle
{s.company_id}{s.signal_type}{s.severity}{s.title}
+
+
+

Open Recommendations

+ {recommendations.slice(0, 20).map((r) => )}
TypeUrgencyScoreTitle
{r.recommendation_type}{r.urgency}{r.decision_score}{r.title}
+ +
} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index bfbd00d..5d3f62b 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -9,21 +9,27 @@ export default function Dashboard() { const [plans, setPlans] = useState([]); const [nodes, setNodes] = useState([]); const [signals, setSignals] = useState([]); + const [companySignals, setCompanySignals] = useState([]); + const [recommendations, setRecommendations] = useState([]); const [error, setError] = useState(''); const load = useCallback(async () => { try { setError(''); - const [o, p, n, s] = await Promise.all([ + const [o, p, n, s, cs, r] = await Promise.all([ api('/opportunities'), api('/plans'), api('/nodes'), api('/signals'), + api('/company-signals'), + api('/recommendations?status=open'), ]); setOpps(o); setPlans(p); setNodes(n); setSignals(s); + setCompanySignals(cs); + setRecommendations(r); } catch (e: any) { setError(e?.message || 'Failed to load dashboard'); } @@ -35,7 +41,7 @@ export default function Dashboard() { const rt = useRealtime(load); const topActions = useMemo(() => plans.filter((p) => !p.completed).slice(0, 4), [plans]); - const byStage = opps.reduce((a, o) => { + const byStage: Record = opps.reduce((a, o) => { a[o.status] = (a[o.status] || 0) + 1; return a; }, {} as Record); @@ -47,11 +53,13 @@ export default function Dashboard() { {error &&
{error}
} -
+
Qualified Pipeline
{opps.length}
Average Score
{(opps.reduce((a, b) => a + b.score_total, 0) / (opps.length || 1)).toFixed(2)}
Active Network Nodes
{nodes.length}
Open Signals
{signals.length}
+
Company Intel Signals
{companySignals.length}
+
Action Recommendations
{recommendations.length}
@@ -63,10 +71,22 @@ export default function Dashboard() {
    {topActions.map((p) =>
  • {p.title}
    {p.due_label}
  • )}
+
+
+

Recent Signals

+
{signals.slice(0, 5).map((s) =>
{s.title}
{s.details}
)}
+
+
+

Recent Company Intelligence

+
{companySignals.slice(0, 5).map((s) =>
{s.signal_type}
{s.title}
)}
+
+
+
-

Recent Signals

-
{signals.slice(0, 5).map((s) =>
{s.title}
{s.details}
)}
+

Top Recommendations

+
{recommendations.slice(0, 6).map((r) =>
{r.title}
{r.reason_summary}
{r.recommendation_type} · urgency {r.urgency} · score {r.decision_score}
)}
+
); } diff --git a/frontend/src/app/execution/page.tsx b/frontend/src/app/execution/page.tsx index e6f397e..c41fd21 100644 --- a/frontend/src/app/execution/page.tsx +++ b/frontend/src/app/execution/page.tsx @@ -2,14 +2,39 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { api } from '@/lib/api'; +interface PlanItem { + id: number; + period_type: string; + title: string; + details: string; + completed: boolean; +} + +interface Recommendation { + id: number; + recommendation_type: string; + urgency: string; + title: string; + suggested_action: string; +} + export default function ExecutionPage() { - const [plans, setPlans] = useState([]); - const load = useCallback(async () => setPlans(await api('/plans')), []); + const [plans, setPlans] = useState([]); + const [recommendations, setRecommendations] = useState([]); + const load = useCallback(async () => { + const [p, r] = await Promise.all([ + api('/plans'), + api('/recommendations?status=open'), + ]); + setPlans(p); + setRecommendations(r); + }, []); useEffect(() => { load(); }, [load]); const toggle = async (id: number) => { await api(`/plans/${id}/toggle`, { method: 'PATCH' }); load(); }; const weekly = useMemo(() => plans.filter((p) => p.period_type === 'weekly'), [plans]); const monthly = useMemo(() => plans.filter((p) => p.period_type === 'monthly'), [plans]); + const actionable = useMemo(() => recommendations.filter((r) => ['NETWORK_ACTION', 'FOLLOW_UP_ACTION'].includes(r.recommendation_type)).slice(0, 8), [recommendations]); return

Execution Plan

@@ -17,5 +42,6 @@ export default function ExecutionPage() {

Weekly Micro-Actions

{weekly.map((p) =>
{p.title}
{p.details}
)}

Monthly Strategic Reviews

{monthly.map((p) =>
{p.title}
{p.details}
)}
+

Decision Engine Actions

{actionable.map((r) =>
{r.title}
{r.suggested_action}
{r.recommendation_type} · {r.urgency}
)}
} diff --git a/frontend/src/app/network/page.tsx b/frontend/src/app/network/page.tsx index 8053f35..101682c 100644 --- a/frontend/src/app/network/page.tsx +++ b/frontend/src/app/network/page.tsx @@ -2,11 +2,28 @@ import { useEffect, useMemo, useState } from 'react'; import { api } from '@/lib/api'; +interface Company { id: number; name: string; industry: string; } +interface PersonNode { + id: number; company_id: number; full_name: string; role_title: string; node_role_type: string; + influence_score: number; accessibility_score: number; relationship_strength: number; +} +interface CompanySignal { id: number; company_id: number; signal_type: string; title: string; } + export default function NetworkPage() { - const [companies, setCompanies] = useState([]); - const [nodes, setNodes] = useState([]); + const [companies, setCompanies] = useState([]); + const [nodes, setNodes] = useState([]); + const [companySignals, setCompanySignals] = useState([]); const [role, setRole] = useState(''); - useEffect(() => { (async () => { setCompanies(await api('/companies')); setNodes(await api('/nodes')); })(); }, []); + useEffect(() => { (async () => { + const [c, n, cs] = await Promise.all([ + api('/companies'), + api('/nodes'), + api('/company-signals'), + ]); + setCompanies(c); + setNodes(n); + setCompanySignals(cs); + })(); }, []); const roleTypes = useMemo(() => Array.from(new Set(nodes.map((n) => n.node_role_type))), [nodes]); @@ -17,7 +34,8 @@ export default function NetworkPage() { .filter((n) => n.company_id === c.id) .filter((n) => !role || n.node_role_type === role) .sort((a, b) => (b.influence_score + b.accessibility_score) - (a.influence_score + a.accessibility_score)); - return

{c.name}

{c.industry}
{companyNodes.map((n) =>
{n.full_name} — {n.role_title}
{n.node_role_type} · Influence {n.influence_score} · Access {n.accessibility_score} · Relationship {n.relationship_strength}
)}
; + const signals = companySignals.filter((s) => s.company_id === c.id).slice(0, 3); + return

{c.name}

{c.industry}
{companyNodes.map((n) =>
{n.full_name} — {n.role_title}
{n.node_role_type} · Influence {n.influence_score} · Access {n.accessibility_score} · Relationship {n.relationship_strength}
)}{signals.length > 0 &&
Company Signals
{signals.map((s) =>
{s.signal_type}: {s.title}
)}
}
; })} } diff --git a/frontend/src/app/opportunities/[id]/page.tsx b/frontend/src/app/opportunities/[id]/page.tsx index 9be9aa7..b9739f6 100644 --- a/frontend/src/app/opportunities/[id]/page.tsx +++ b/frontend/src/app/opportunities/[id]/page.tsx @@ -6,6 +6,7 @@ export default function OpportunityDetail({ params }: { params: { id: string } } const [opp, setOpp] = useState(); const [nodes, setNodes] = useState([]); const [signals, setSignals] = useState([]); + const [recommendations, setRecommendations] = useState([]); const [error, setError] = useState(''); useEffect(() => { @@ -15,6 +16,7 @@ export default function OpportunityDetail({ params }: { params: { id: string } } setOpp(await api(`/opportunities/${params.id}`)); setNodes(await api(`/opportunities/${params.id}/network-recommendations`)); setSignals(await api(`/opportunities/${params.id}/signals`)); + setRecommendations(await api(`/recommendations?status=open`)); } catch (e: any) { setError(e?.message || 'Failed to load opportunity'); } @@ -37,5 +39,6 @@ export default function OpportunityDetail({ params }: { params: { id: string } }

Signals

{signals.length === 0 ?
No active signals.
: signals.map((s) =>
{s.title}
{s.details}
)}

Recommended Network Entry Points

{nodes.length === 0 ?
No linked network nodes yet.
: nodes.map((n) =>
{n.full_name} — {n.node_role_type} (Influence {n.influence_score}, Access {n.accessibility_score})
)}
+

Decision Engine Recommendations

{recommendations.filter((r) => r.entity_type === 'opportunity' && r.entity_id === opp.id).slice(0, 5).map((r) =>
{r.title}
{r.reason_summary}
{r.recommendation_type} · {r.urgency} · score {r.decision_score}
)}
; } diff --git a/frontend/src/app/opportunities/page.tsx b/frontend/src/app/opportunities/page.tsx index 2987df8..b57edbd 100644 --- a/frontend/src/app/opportunities/page.tsx +++ b/frontend/src/app/opportunities/page.tsx @@ -46,7 +46,7 @@ export default function OpportunitiesPage() {
- {filtered.map((i) => )} + {filtered.map((i) => )}
CompanyRoleScoreStatusSignals
{i.company}{i.role_title}{i.score_total}{i.status}{(signalMap[i.id] || []).slice(0, 2).map((s) => {s.signal_type})}
{i.company}{i.role_title}{i.score_total}{i.status}{(signalMap[i.id] || []).slice(0, 2).map((s: { id: number; signal_type: string }) => {s.signal_type})}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3176b8c..46e89eb 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -23,7 +23,7 @@ export async function api(path: string, init?: RequestInit): Promise { if (!res.ok) { throw new ApiError(res.status, await res.text()); } - return res.json(); + return (await res.json()) as T; } export { API };