feat: LLM reply draft, tiers BYOK gate, and messaging API endpoints (#74)
This commit is contained in:
parent
091834f1ae
commit
715a8aa33e
3 changed files with 211 additions and 0 deletions
|
|
@ -49,6 +49,7 @@ FEATURES: dict[str, str] = {
|
||||||
"company_research": "paid",
|
"company_research": "paid",
|
||||||
"interview_prep": "paid",
|
"interview_prep": "paid",
|
||||||
"survey_assistant": "paid",
|
"survey_assistant": "paid",
|
||||||
|
"llm_reply_draft": "paid",
|
||||||
|
|
||||||
# Orchestration / infrastructure — stays gated
|
# Orchestration / infrastructure — stays gated
|
||||||
"email_classifier": "paid",
|
"email_classifier": "paid",
|
||||||
|
|
@ -81,6 +82,7 @@ BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
||||||
"company_research",
|
"company_research",
|
||||||
"interview_prep",
|
"interview_prep",
|
||||||
"survey_assistant",
|
"survey_assistant",
|
||||||
|
"llm_reply_draft",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Demo mode flag — read from environment at module load time.
|
# Demo mode flag — read from environment at module load time.
|
||||||
|
|
|
||||||
167
dev-api.py
167
dev-api.py
|
|
@ -4178,3 +4178,170 @@ def wizard_complete():
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Messaging models ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MessageCreateBody(BaseModel):
|
||||||
|
job_id: Optional[int] = None
|
||||||
|
job_contact_id: Optional[int] = None
|
||||||
|
type: str = "email"
|
||||||
|
direction: Optional[str] = None
|
||||||
|
subject: Optional[str] = None
|
||||||
|
body: Optional[str] = None
|
||||||
|
from_addr: Optional[str] = None
|
||||||
|
to_addr: Optional[str] = None
|
||||||
|
template_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateCreateBody(BaseModel):
|
||||||
|
title: str
|
||||||
|
category: str = "custom"
|
||||||
|
subject_template: Optional[str] = None
|
||||||
|
body_template: str
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateUpdateBody(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
subject_template: Optional[str] = None
|
||||||
|
body_template: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Messaging (MIT) ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/messages")
|
||||||
|
def get_messages(
|
||||||
|
job_id: Optional[int] = None,
|
||||||
|
type: Optional[str] = None,
|
||||||
|
direction: Optional[str] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
):
|
||||||
|
from scripts.messaging import list_messages
|
||||||
|
return list_messages(
|
||||||
|
Path(_request_db.get() or DB_PATH),
|
||||||
|
job_id=job_id, type=type, direction=direction, limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/messages")
|
||||||
|
def post_message(body: MessageCreateBody):
|
||||||
|
from scripts.messaging import create_message
|
||||||
|
return create_message(Path(_request_db.get() or DB_PATH), **body.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/messages/{message_id}")
|
||||||
|
def del_message(message_id: int):
|
||||||
|
from scripts.messaging import delete_message
|
||||||
|
try:
|
||||||
|
delete_message(Path(_request_db.get() or DB_PATH), message_id)
|
||||||
|
return {"ok": True}
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "message not found")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/message-templates")
|
||||||
|
def get_templates():
|
||||||
|
from scripts.messaging import list_templates
|
||||||
|
return list_templates(Path(_request_db.get() or DB_PATH))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/message-templates")
|
||||||
|
def post_template(body: TemplateCreateBody):
|
||||||
|
from scripts.messaging import create_template
|
||||||
|
return create_template(Path(_request_db.get() or DB_PATH), **body.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/message-templates/{template_id}")
|
||||||
|
def put_template(template_id: int, body: TemplateUpdateBody):
|
||||||
|
from scripts.messaging import update_template
|
||||||
|
try:
|
||||||
|
return update_template(
|
||||||
|
Path(_request_db.get() or DB_PATH),
|
||||||
|
template_id,
|
||||||
|
**body.model_dump(exclude_none=True),
|
||||||
|
)
|
||||||
|
except PermissionError:
|
||||||
|
raise HTTPException(403, "cannot modify built-in templates")
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "template not found")
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/message-templates/{template_id}")
|
||||||
|
def del_template(template_id: int):
|
||||||
|
from scripts.messaging import delete_template
|
||||||
|
try:
|
||||||
|
delete_template(Path(_request_db.get() or DB_PATH), template_id)
|
||||||
|
return {"ok": True}
|
||||||
|
except PermissionError:
|
||||||
|
raise HTTPException(403, "cannot delete built-in templates")
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "template not found")
|
||||||
|
|
||||||
|
|
||||||
|
# ── LLM Reply Draft (BSL 1.1) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_effective_tier(request: Request) -> str:
|
||||||
|
"""Resolve effective tier from request header or environment."""
|
||||||
|
header_tier = request.headers.get("X-CF-Tier")
|
||||||
|
if header_tier:
|
||||||
|
return header_tier
|
||||||
|
from app.wizard.tiers import effective_tier
|
||||||
|
return effective_tier()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/contacts/{contact_id}/draft-reply")
|
||||||
|
def draft_reply(contact_id: int, request: Request):
|
||||||
|
"""Generate an LLM draft reply for an inbound job_contacts row. Tier-gated."""
|
||||||
|
from app.wizard.tiers import can_use, has_configured_llm
|
||||||
|
from scripts.messaging import create_message
|
||||||
|
from scripts.llm_reply_draft import generate_draft_reply
|
||||||
|
|
||||||
|
db_path = Path(_request_db.get() or DB_PATH)
|
||||||
|
tier = _get_effective_tier(request)
|
||||||
|
if not can_use(tier, "llm_reply_draft", has_byok=has_configured_llm()):
|
||||||
|
raise HTTPException(402, detail={"error": "tier_required", "min_tier": "free+byok"})
|
||||||
|
|
||||||
|
con = _get_db()
|
||||||
|
row = con.execute("SELECT * FROM job_contacts WHERE id=?", (contact_id,)).fetchone()
|
||||||
|
con.close()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "contact not found")
|
||||||
|
|
||||||
|
profile = _imitate_load_profile()
|
||||||
|
user_name = getattr(profile, "name", "") or ""
|
||||||
|
target_role = getattr(profile, "target_role", "") or ""
|
||||||
|
|
||||||
|
cfg_path = db_path.parent / "config" / "llm.yaml"
|
||||||
|
draft_body = generate_draft_reply(
|
||||||
|
subject=row["subject"] or "",
|
||||||
|
from_addr=row["from_addr"] or "",
|
||||||
|
body=row["body"] or "",
|
||||||
|
user_name=user_name,
|
||||||
|
target_role=target_role,
|
||||||
|
config_path=cfg_path if cfg_path.exists() else None,
|
||||||
|
)
|
||||||
|
msg = create_message(
|
||||||
|
db_path,
|
||||||
|
job_id=row["job_id"],
|
||||||
|
job_contact_id=contact_id,
|
||||||
|
type="draft",
|
||||||
|
direction="outbound",
|
||||||
|
subject=f"Re: {row['subject'] or ''}".strip(),
|
||||||
|
body=draft_body,
|
||||||
|
to_addr=row["from_addr"],
|
||||||
|
template_id=None,
|
||||||
|
from_addr=None,
|
||||||
|
)
|
||||||
|
return {"message_id": msg["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/messages/{message_id}/approve")
|
||||||
|
def approve_message_endpoint(message_id: int):
|
||||||
|
"""Set approved_at=now(). Returns approved body for copy-to-clipboard."""
|
||||||
|
from scripts.messaging import approve_message
|
||||||
|
try:
|
||||||
|
msg = approve_message(Path(_request_db.get() or DB_PATH), message_id)
|
||||||
|
return {"body": msg["body"], "approved_at": msg["approved_at"]}
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "message not found")
|
||||||
|
|
|
||||||
42
scripts/llm_reply_draft.py
Normal file
42
scripts/llm_reply_draft.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# BSL 1.1 — see LICENSE-BSL
|
||||||
|
"""LLM-assisted reply draft generation for inbound job contacts (BSL 1.1)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
_SYSTEM = (
|
||||||
|
"You are drafting a professional email reply on behalf of a job seeker. "
|
||||||
|
"Be concise and professional. Do not fabricate facts. If you are uncertain "
|
||||||
|
"about a detail, leave a [TODO: fill in] placeholder. "
|
||||||
|
"Output the reply body only — no subject line, no salutation preamble."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_prompt(subject: str, from_addr: str, body: str, user_name: str, target_role: str) -> str:
|
||||||
|
return (
|
||||||
|
f"ORIGINAL EMAIL:\n"
|
||||||
|
f"Subject: {subject}\n"
|
||||||
|
f"From: {from_addr}\n"
|
||||||
|
f"Body:\n{body}\n\n"
|
||||||
|
f"USER PROFILE CONTEXT:\n"
|
||||||
|
f"Name: {user_name}\n"
|
||||||
|
f"Target role: {target_role}\n\n"
|
||||||
|
"Write a concise, professional reply to this email."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_draft_reply(
|
||||||
|
subject: str,
|
||||||
|
from_addr: str,
|
||||||
|
body: str,
|
||||||
|
user_name: str,
|
||||||
|
target_role: str,
|
||||||
|
config_path: Optional[Path] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Return a draft reply body string."""
|
||||||
|
from scripts.llm_router import LLMRouter
|
||||||
|
|
||||||
|
router = LLMRouter(config_path=config_path)
|
||||||
|
prompt = _build_prompt(subject, from_addr, body, user_name, target_role)
|
||||||
|
return router.complete(system=_SYSTEM, user=prompt).strip()
|
||||||
Loading…
Reference in a new issue