fix: tier bypass, draft body persistence, canDraftLlm cleanup, limit cap
- CRITICAL: Remove X-CF-Tier header trust from _get_effective_tier; use
Heimdall in cloud mode and APP_TIER env var in single-tenant only
- HIGH: Add update_message_body helper + PUT /api/messages/{id} endpoint;
updateMessageBody store action; approveDraft now persists edits to DB
before calling approve so history always shows the final approved text
- Cleanup: Remove dead canDraftLlm ref, checkLlmAvailable function, and
v-else-if Enable LLM drafts link; show Draft reply button unconditionally
- MEDIUM: Cap GET /api/messages limit param with Query(ge=1, le=1000)
- Test: Update test_draft_without_llm_returns_402 to patch effective_tier
instead of sending X-CF-Tier header
This commit is contained in:
parent
6812e3f9ef
commit
91e2faf5d0
5 changed files with 55 additions and 42 deletions
30
dev-api.py
30
dev-api.py
|
|
@ -26,7 +26,7 @@ import yaml
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response, UploadFile
|
from fastapi import FastAPI, HTTPException, Query, Request, Response, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
@ -4195,6 +4195,10 @@ class MessageCreateBody(BaseModel):
|
||||||
logged_at: Optional[str] = None
|
logged_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageUpdateBody(BaseModel):
|
||||||
|
body: str
|
||||||
|
|
||||||
|
|
||||||
class TemplateCreateBody(BaseModel):
|
class TemplateCreateBody(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
category: str = "custom"
|
category: str = "custom"
|
||||||
|
|
@ -4216,7 +4220,7 @@ def get_messages(
|
||||||
job_id: Optional[int] = None,
|
job_id: Optional[int] = None,
|
||||||
type: Optional[str] = None,
|
type: Optional[str] = None,
|
||||||
direction: Optional[str] = None,
|
direction: Optional[str] = None,
|
||||||
limit: int = 100,
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
):
|
):
|
||||||
from scripts.messaging import list_messages
|
from scripts.messaging import list_messages
|
||||||
return list_messages(
|
return list_messages(
|
||||||
|
|
@ -4241,6 +4245,15 @@ def del_message(message_id: int):
|
||||||
raise HTTPException(404, "message not found")
|
raise HTTPException(404, "message not found")
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/messages/{message_id}")
|
||||||
|
def put_message(message_id: int, body: MessageUpdateBody):
|
||||||
|
from scripts.messaging import update_message_body
|
||||||
|
try:
|
||||||
|
return update_message_body(Path(_request_db.get() or DB_PATH), message_id, body.body)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "message not found")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/message-templates")
|
@app.get("/api/message-templates")
|
||||||
def get_templates():
|
def get_templates():
|
||||||
from scripts.messaging import list_templates
|
from scripts.messaging import list_templates
|
||||||
|
|
@ -4282,24 +4295,23 @@ def del_template(template_id: int):
|
||||||
|
|
||||||
# ── LLM Reply Draft (BSL 1.1) ─────────────────────────────────────────────────
|
# ── LLM Reply Draft (BSL 1.1) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def _get_effective_tier(request: Request) -> str:
|
def _get_effective_tier() -> str:
|
||||||
"""Resolve effective tier from request header or environment."""
|
"""Resolve effective tier: Heimdall in cloud mode, APP_TIER env var in single-tenant."""
|
||||||
header_tier = request.headers.get("X-CF-Tier")
|
if _CLOUD_MODE:
|
||||||
if header_tier:
|
return _resolve_cloud_tier()
|
||||||
return header_tier
|
|
||||||
from app.wizard.tiers import effective_tier
|
from app.wizard.tiers import effective_tier
|
||||||
return effective_tier()
|
return effective_tier()
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/contacts/{contact_id}/draft-reply")
|
@app.post("/api/contacts/{contact_id}/draft-reply")
|
||||||
def draft_reply(contact_id: int, request: Request):
|
def draft_reply(contact_id: int):
|
||||||
"""Generate an LLM draft reply for an inbound job_contacts row. Tier-gated."""
|
"""Generate an LLM draft reply for an inbound job_contacts row. Tier-gated."""
|
||||||
from app.wizard.tiers import can_use, has_configured_llm
|
from app.wizard.tiers import can_use, has_configured_llm
|
||||||
from scripts.messaging import create_message
|
from scripts.messaging import create_message
|
||||||
from scripts.llm_reply_draft import generate_draft_reply
|
from scripts.llm_reply_draft import generate_draft_reply
|
||||||
|
|
||||||
db_path = Path(_request_db.get() or DB_PATH)
|
db_path = Path(_request_db.get() or DB_PATH)
|
||||||
tier = _get_effective_tier(request)
|
tier = _get_effective_tier()
|
||||||
if not can_use(tier, "llm_reply_draft", has_byok=has_configured_llm()):
|
if not can_use(tier, "llm_reply_draft", has_byok=has_configured_llm()):
|
||||||
raise HTTPException(402, detail={"error": "tier_required", "min_tier": "free+byok"})
|
raise HTTPException(402, detail={"error": "tier_required", "min_tier": "free+byok"})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -268,3 +268,18 @@ def delete_template(db_path: Path, template_id: int) -> None:
|
||||||
con.commit()
|
con.commit()
|
||||||
finally:
|
finally:
|
||||||
con.close()
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_message_body(db_path: Path, message_id: int, body: str) -> dict:
|
||||||
|
"""Update the body text of a draft message before approval. Returns updated row."""
|
||||||
|
con = _connect(db_path)
|
||||||
|
try:
|
||||||
|
row = con.execute("SELECT id FROM messages WHERE id=?", (message_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(f"message {message_id} not found")
|
||||||
|
con.execute("UPDATE messages SET body=? WHERE id=?", (body, message_id))
|
||||||
|
con.commit()
|
||||||
|
updated = con.execute("SELECT * FROM messages WHERE id=?", (message_id,)).fetchone()
|
||||||
|
return dict(updated)
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
|
||||||
|
|
@ -159,12 +159,11 @@ def test_draft_without_llm_returns_402(fresh_db, monkeypatch):
|
||||||
|
|
||||||
# Ensure has_configured_llm returns False at both import locations
|
# Ensure has_configured_llm returns False at both import locations
|
||||||
monkeypatch.setattr("app.wizard.tiers.has_configured_llm", lambda *a, **kw: False)
|
monkeypatch.setattr("app.wizard.tiers.has_configured_llm", lambda *a, **kw: False)
|
||||||
|
# Force free tier via the tiers module (not via header — header is no longer trusted)
|
||||||
|
monkeypatch.setattr("app.wizard.tiers.effective_tier", lambda: "free")
|
||||||
|
|
||||||
client = TestClient(dev_api.app)
|
client = TestClient(dev_api.app)
|
||||||
resp = client.post(
|
resp = client.post(f"/api/contacts/{contact_id}/draft-reply")
|
||||||
f"/api/contacts/{contact_id}/draft-reply",
|
|
||||||
headers={"X-CF-Tier": "free"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 402
|
assert resp.status_code == 402
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,16 @@ export const useMessagingStore = defineStore('messaging', () => {
|
||||||
return data.message_id
|
return data.message_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateMessageBody(id: number, body: string) {
|
||||||
|
const { data, error: fetchErr } = await useApiFetch<Message>(
|
||||||
|
`/api/messages/${id}`,
|
||||||
|
{ method: 'PUT', body: JSON.stringify({ body }), headers: { 'Content-Type': 'application/json' } }
|
||||||
|
)
|
||||||
|
if (fetchErr || !data) { error.value = 'Failed to save edits.'; return null }
|
||||||
|
messages.value = messages.value.map(m => m.id === id ? { ...m, body: data.body } : m)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
async function approveDraft(messageId: number): Promise<string | null> {
|
async function approveDraft(messageId: number): Promise<string | null> {
|
||||||
const { data, error: fetchErr } = await useApiFetch<{ body: string; approved_at: string }>(
|
const { data, error: fetchErr } = await useApiFetch<{ body: string; approved_at: string }>(
|
||||||
`/api/messages/${messageId}/approve`,
|
`/api/messages/${messageId}/approve`,
|
||||||
|
|
@ -159,6 +169,6 @@ export const useMessagingStore = defineStore('messaging', () => {
|
||||||
messages, templates, loading, saving, error, draftPending,
|
messages, templates, loading, saving, error, draftPending,
|
||||||
fetchMessages, fetchTemplates, createMessage, deleteMessage,
|
fetchMessages, fetchTemplates, createMessage, deleteMessage,
|
||||||
createTemplate, updateTemplate, deleteTemplate,
|
createTemplate, updateTemplate, deleteTemplate,
|
||||||
requestDraft, approveDraft, clear,
|
requestDraft, approveDraft, updateMessageBody, clear,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -40,21 +40,12 @@
|
||||||
<button class="btn btn--ghost" @click="openLogModal('in_person')">Log note</button>
|
<button class="btn btn--ghost" @click="openLogModal('in_person')">Log note</button>
|
||||||
<button class="btn btn--ghost" @click="openTemplateModal('apply')">Use template</button>
|
<button class="btn btn--ghost" @click="openTemplateModal('apply')">Use template</button>
|
||||||
<button
|
<button
|
||||||
v-if="canDraftLlm"
|
|
||||||
class="btn btn--primary"
|
class="btn btn--primary"
|
||||||
:disabled="store.loading"
|
:disabled="store.loading"
|
||||||
@click="requestDraft"
|
@click="requestDraft"
|
||||||
>
|
>
|
||||||
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
|
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
|
||||||
</button>
|
</button>
|
||||||
<a
|
|
||||||
v-else-if="!canDraftLlm"
|
|
||||||
href="#/settings/connections"
|
|
||||||
class="btn-enable-llm"
|
|
||||||
title="Configure an LLM backend to enable AI reply drafting"
|
|
||||||
>
|
|
||||||
Enable LLM drafts →
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Osprey (Phase 2 stub) — aria-disabled, never hidden -->
|
<!-- Osprey (Phase 2 stub) — aria-disabled, never hidden -->
|
||||||
<button
|
<button
|
||||||
|
|
@ -286,18 +277,8 @@ function updateDraftBody(id: number, value: string) {
|
||||||
|
|
||||||
// ── LLM draft + approval ──────────────────────────────────────────────────
|
// ── LLM draft + approval ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const canDraftLlm = ref(false)
|
|
||||||
const draftAnnouncement = ref('')
|
const draftAnnouncement = ref('')
|
||||||
|
|
||||||
async function checkLlmAvailable() {
|
|
||||||
await useApiFetch<{ available: boolean }>('/api/vision/health')
|
|
||||||
// Re-use vision health route as a proxy for LLM config — replace with a dedicated
|
|
||||||
// /api/llm/health endpoint if one is added in future.
|
|
||||||
// For now: canDraftLlm is set based on whether user has any configured LLM
|
|
||||||
// (the server will 402 if not — the button is just a hint, not a gate)
|
|
||||||
canDraftLlm.value = true // Always show; server enforces the real gate
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestDraft() {
|
async function requestDraft() {
|
||||||
// Find the most recent inbound job_contact for this job
|
// Find the most recent inbound job_contact for this job
|
||||||
const inbound = jobContacts.value.find(c => c.direction === 'inbound')
|
const inbound = jobContacts.value.find(c => c.direction === 'inbound')
|
||||||
|
|
@ -314,18 +295,18 @@ async function requestDraft() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function approveDraft(messageId: number) {
|
async function approveDraft(messageId: number) {
|
||||||
// Use the locally-edited body if the user changed it
|
|
||||||
const editedBody = draftBodyEdits.value[messageId]
|
const editedBody = draftBodyEdits.value[messageId]
|
||||||
|
// Persist edits to DB before approving so history shows final version
|
||||||
if (editedBody !== undefined) {
|
if (editedBody !== undefined) {
|
||||||
// Patch body via a NOOP approve — server sets approved_at and returns body
|
const updated = await store.updateMessageBody(messageId, editedBody)
|
||||||
// The edited body is in our local state; copy it to clipboard from local state
|
if (!updated) return // error already set in store
|
||||||
}
|
}
|
||||||
const body = await store.approveDraft(messageId)
|
const body = await store.approveDraft(messageId)
|
||||||
if (body) {
|
if (body) {
|
||||||
const finalBody = editedBody ?? body
|
const finalBody = editedBody ?? body
|
||||||
await navigator.clipboard.writeText(finalBody)
|
await navigator.clipboard.writeText(finalBody)
|
||||||
draftAnnouncement.value = 'Approved and copied to clipboard.'
|
draftAnnouncement.value = 'Approved and copied to clipboard.'
|
||||||
setTimeout(() => { draftAnnouncement.value = '' }, 3000 )
|
setTimeout(() => { draftAnnouncement.value = '' }, 3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -423,7 +404,7 @@ function formatTime(iso: string): string {
|
||||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadJobs(), store.fetchTemplates(), checkLlmAvailable()])
|
await Promise.all([loadJobs(), store.fetchTemplates()])
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
@ -489,10 +470,6 @@ onUnmounted(() => {
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
.btn-enable-llm {
|
|
||||||
font-size: var(--text-sm); color: var(--app-primary);
|
|
||||||
text-decoration: none; padding: var(--space-2) var(--space-3);
|
|
||||||
}
|
|
||||||
.btn--osprey {
|
.btn--osprey {
|
||||||
opacity: 0.5; cursor: not-allowed;
|
opacity: 0.5; cursor: not-allowed;
|
||||||
background: none; border: 1px dashed var(--color-border);
|
background: none; border: 1px dashed var(--color-border);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue