Implement business plans
This commit is contained in:
0
core/commands/__init__.py
Normal file
0
core/commands/__init__.py
Normal file
29
core/commands/base.py
Normal file
29
core/commands/base.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CommandContext:
|
||||
service: str
|
||||
channel_identifier: str
|
||||
message_id: str
|
||||
user_id: int
|
||||
message_text: str
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CommandResult:
|
||||
ok: bool
|
||||
status: str = "ok"
|
||||
error: str = ""
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class CommandHandler:
|
||||
slug = ""
|
||||
|
||||
async def execute(self, ctx: CommandContext) -> CommandResult:
|
||||
raise NotImplementedError
|
||||
125
core/commands/engine.py
Normal file
125
core/commands/engine.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
|
||||
from core.commands.base import CommandContext, CommandResult
|
||||
from core.commands.handlers.bp import BPCommandHandler
|
||||
from core.commands.registry import get as get_handler
|
||||
from core.commands.registry import register
|
||||
from core.messaging.reply_sync import is_mirrored_origin
|
||||
from core.models import CommandChannelBinding, CommandProfile, Message
|
||||
from core.util import logs
|
||||
|
||||
log = logs.get_logger("command_engine")
|
||||
|
||||
_REGISTERED = False
|
||||
|
||||
|
||||
def ensure_handlers_registered():
|
||||
global _REGISTERED
|
||||
if _REGISTERED:
|
||||
return
|
||||
register(BPCommandHandler())
|
||||
_REGISTERED = True
|
||||
|
||||
|
||||
async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]:
|
||||
def _load():
|
||||
direct = list(
|
||||
CommandProfile.objects.filter(
|
||||
user_id=ctx.user_id,
|
||||
enabled=True,
|
||||
channel_bindings__enabled=True,
|
||||
channel_bindings__direction="ingress",
|
||||
channel_bindings__service=ctx.service,
|
||||
channel_bindings__channel_identifier=ctx.channel_identifier,
|
||||
).distinct()
|
||||
)
|
||||
if direct:
|
||||
return direct
|
||||
# Compose-originated messages use `web` service even when the
|
||||
# underlying conversation is mapped to a platform identifier.
|
||||
if str(ctx.service or "").strip().lower() != "web":
|
||||
return []
|
||||
trigger = (
|
||||
Message.objects.select_related("session", "session__identifier")
|
||||
.filter(id=ctx.message_id, user_id=ctx.user_id)
|
||||
.first()
|
||||
)
|
||||
identifier = getattr(getattr(trigger, "session", None), "identifier", None)
|
||||
fallback_service = str(getattr(identifier, "service", "") or "").strip().lower()
|
||||
fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip()
|
||||
if not fallback_service or not fallback_identifier:
|
||||
return []
|
||||
return list(
|
||||
CommandProfile.objects.filter(
|
||||
user_id=ctx.user_id,
|
||||
enabled=True,
|
||||
channel_bindings__enabled=True,
|
||||
channel_bindings__direction="ingress",
|
||||
channel_bindings__service=fallback_service,
|
||||
channel_bindings__channel_identifier=fallback_identifier,
|
||||
).distinct()
|
||||
)
|
||||
|
||||
return await sync_to_async(_load)()
|
||||
|
||||
|
||||
def _matches_trigger(profile: CommandProfile, text: str) -> bool:
|
||||
body = str(text or "").strip()
|
||||
trigger = str(profile.trigger_token or "").strip()
|
||||
if not trigger:
|
||||
return False
|
||||
if profile.exact_match_only:
|
||||
return body == trigger
|
||||
return trigger in body
|
||||
|
||||
|
||||
async def process_inbound_message(ctx: CommandContext) -> list[CommandResult]:
|
||||
ensure_handlers_registered()
|
||||
trigger_message = await sync_to_async(
|
||||
lambda: Message.objects.filter(id=ctx.message_id).first()
|
||||
)()
|
||||
if trigger_message is None:
|
||||
return []
|
||||
if is_mirrored_origin(trigger_message.message_meta):
|
||||
return []
|
||||
|
||||
profiles = await _eligible_profiles(ctx)
|
||||
results: list[CommandResult] = []
|
||||
for profile in profiles:
|
||||
if not _matches_trigger(profile, ctx.message_text):
|
||||
continue
|
||||
if profile.reply_required and trigger_message.reply_to_id is None:
|
||||
results.append(
|
||||
CommandResult(
|
||||
ok=False,
|
||||
status="skipped",
|
||||
error="reply_required",
|
||||
payload={"profile": profile.slug},
|
||||
)
|
||||
)
|
||||
continue
|
||||
handler = get_handler(profile.slug)
|
||||
if handler is None:
|
||||
results.append(
|
||||
CommandResult(
|
||||
ok=False,
|
||||
status="failed",
|
||||
error=f"missing_handler:{profile.slug}",
|
||||
)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
result = await handler.execute(ctx)
|
||||
results.append(result)
|
||||
except Exception as exc:
|
||||
log.exception("command execution failed for profile=%s: %s", profile.slug, exc)
|
||||
results.append(
|
||||
CommandResult(
|
||||
ok=False,
|
||||
status="failed",
|
||||
error=f"handler_exception:{exc}",
|
||||
)
|
||||
)
|
||||
return results
|
||||
0
core/commands/handlers/__init__.py
Normal file
0
core/commands/handlers/__init__.py
Normal file
358
core/commands/handlers/bp.py
Normal file
358
core/commands/handlers/bp.py
Normal file
@@ -0,0 +1,358 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
|
||||
from core.clients import transport
|
||||
from core.commands.base import CommandContext, CommandHandler, CommandResult
|
||||
from core.messaging import ai as ai_runner
|
||||
from core.messaging.utils import messages_to_string
|
||||
from core.models import (
|
||||
AI,
|
||||
BusinessPlanDocument,
|
||||
BusinessPlanRevision,
|
||||
ChatSession,
|
||||
CommandAction,
|
||||
CommandChannelBinding,
|
||||
CommandRun,
|
||||
Message,
|
||||
)
|
||||
|
||||
|
||||
def _bp_system_prompt():
|
||||
return (
|
||||
"Create a structured business plan using the given template. "
|
||||
"Follow the template section order exactly. "
|
||||
"If data is missing, write concise assumptions and risks. "
|
||||
"Return markdown only."
|
||||
)
|
||||
|
||||
|
||||
def _clamp_transcript(transcript: str, max_chars: int) -> str:
|
||||
text = str(transcript or "")
|
||||
if max_chars <= 0 or len(text) <= max_chars:
|
||||
return text
|
||||
head_size = min(2000, max_chars // 3)
|
||||
tail_size = max(0, max_chars - head_size - 140)
|
||||
omitted = len(text) - head_size - tail_size
|
||||
return (
|
||||
text[:head_size].rstrip()
|
||||
+ f"\n\n[... truncated {max(0, omitted)} chars ...]\n\n"
|
||||
+ text[-tail_size:].lstrip()
|
||||
)
|
||||
|
||||
|
||||
def _bp_fallback_markdown(template_text: str, transcript: str, error_text: str = "") -> str:
|
||||
header = (
|
||||
"## Business Plan (Draft)\n\n"
|
||||
"Automatic fallback was used because AI generation failed for this run.\n"
|
||||
)
|
||||
if error_text:
|
||||
header += f"\nFailure: `{error_text}`\n"
|
||||
return (
|
||||
f"{header}\n"
|
||||
"### Template\n"
|
||||
f"{template_text}\n\n"
|
||||
"### Transcript Window\n"
|
||||
f"{transcript}"
|
||||
)
|
||||
|
||||
|
||||
def _chunk_for_transport(text: str, limit: int = 3000) -> list[str]:
|
||||
body = str(text or "").strip()
|
||||
if not body:
|
||||
return []
|
||||
if len(body) <= limit:
|
||||
return [body]
|
||||
parts = []
|
||||
remaining = body
|
||||
while len(remaining) > limit:
|
||||
cut = remaining.rfind("\n\n", 0, limit)
|
||||
if cut < int(limit * 0.45):
|
||||
cut = remaining.rfind("\n", 0, limit)
|
||||
if cut < int(limit * 0.35):
|
||||
cut = limit
|
||||
parts.append(remaining[:cut].rstrip())
|
||||
remaining = remaining[cut:].lstrip()
|
||||
if remaining:
|
||||
parts.append(remaining)
|
||||
return [part for part in parts if part]
|
||||
|
||||
|
||||
class BPCommandHandler(CommandHandler):
|
||||
slug = "bp"
|
||||
|
||||
async def _status_message(self, trigger_message: Message, text: str):
|
||||
service = str(trigger_message.source_service or "").strip().lower()
|
||||
if service == "web":
|
||||
await sync_to_async(Message.objects.create)(
|
||||
user=trigger_message.user,
|
||||
session=trigger_message.session,
|
||||
sender_uuid="",
|
||||
text=text,
|
||||
ts=int(time.time() * 1000),
|
||||
custom_author="BOT",
|
||||
source_service="web",
|
||||
source_chat_id=trigger_message.source_chat_id or "",
|
||||
)
|
||||
return
|
||||
if service == "xmpp" and str(trigger_message.source_chat_id or "").strip():
|
||||
try:
|
||||
await transport.send_message_raw(
|
||||
"xmpp",
|
||||
str(trigger_message.source_chat_id or "").strip(),
|
||||
text=text,
|
||||
attachments=[],
|
||||
metadata={"origin_tag": f"bp-status:{trigger_message.id}"},
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
async def _fanout(self, run: CommandRun, text: str) -> dict:
|
||||
profile = run.profile
|
||||
trigger = await sync_to_async(
|
||||
lambda: Message.objects.select_related("session", "user")
|
||||
.filter(id=run.trigger_message_id)
|
||||
.first()
|
||||
)()
|
||||
if trigger is None:
|
||||
return {"sent_bindings": 0, "failed_bindings": 0}
|
||||
bindings = await sync_to_async(list)(
|
||||
CommandChannelBinding.objects.filter(
|
||||
profile=profile,
|
||||
enabled=True,
|
||||
direction="egress",
|
||||
)
|
||||
)
|
||||
sent_bindings = 0
|
||||
failed_bindings = 0
|
||||
for binding in bindings:
|
||||
if binding.service == "web":
|
||||
session = None
|
||||
channel_identifier = str(binding.channel_identifier or "").strip()
|
||||
if (
|
||||
channel_identifier
|
||||
and channel_identifier == str(trigger.source_chat_id or "").strip()
|
||||
):
|
||||
session = trigger.session
|
||||
if session is None and channel_identifier:
|
||||
session = await sync_to_async(
|
||||
lambda: ChatSession.objects.filter(
|
||||
user=trigger.user,
|
||||
identifier__identifier=channel_identifier,
|
||||
)
|
||||
.order_by("-last_interaction")
|
||||
.first()
|
||||
)()
|
||||
if session is None:
|
||||
session = trigger.session
|
||||
await sync_to_async(Message.objects.create)(
|
||||
user=trigger.user,
|
||||
session=session,
|
||||
sender_uuid="",
|
||||
text=text,
|
||||
ts=int(time.time() * 1000),
|
||||
custom_author="BOT",
|
||||
source_service="web",
|
||||
source_chat_id=channel_identifier or str(trigger.source_chat_id or ""),
|
||||
message_meta={"origin_tag": f"bp:{run.id}"},
|
||||
)
|
||||
sent_bindings += 1
|
||||
continue
|
||||
try:
|
||||
chunks = _chunk_for_transport(text, limit=3000)
|
||||
if not chunks:
|
||||
failed_bindings += 1
|
||||
continue
|
||||
ok = True
|
||||
for chunk in chunks:
|
||||
ts = await transport.send_message_raw(
|
||||
binding.service,
|
||||
binding.channel_identifier,
|
||||
text=chunk,
|
||||
attachments=[],
|
||||
metadata={
|
||||
"origin_tag": f"bp:{run.id}",
|
||||
"command_slug": "bp",
|
||||
},
|
||||
)
|
||||
if not ts:
|
||||
ok = False
|
||||
break
|
||||
if ok:
|
||||
sent_bindings += 1
|
||||
else:
|
||||
failed_bindings += 1
|
||||
except Exception:
|
||||
failed_bindings += 1
|
||||
return {"sent_bindings": sent_bindings, "failed_bindings": failed_bindings}
|
||||
|
||||
async def execute(self, ctx: CommandContext) -> CommandResult:
|
||||
trigger = await sync_to_async(
|
||||
lambda: Message.objects.select_related("user", "session")
|
||||
.filter(id=ctx.message_id)
|
||||
.first()
|
||||
)()
|
||||
if trigger is None:
|
||||
return CommandResult(ok=False, status="failed", error="trigger_not_found")
|
||||
|
||||
profile = await sync_to_async(
|
||||
lambda: trigger.user.commandprofile_set.filter(slug=self.slug, enabled=True)
|
||||
.first()
|
||||
)()
|
||||
if profile is None:
|
||||
return CommandResult(ok=False, status="skipped", error="profile_missing")
|
||||
|
||||
actions = await sync_to_async(list)(
|
||||
CommandAction.objects.filter(
|
||||
profile=profile,
|
||||
enabled=True,
|
||||
).order_by("position", "id")
|
||||
)
|
||||
action_types = {row.action_type for row in actions}
|
||||
if "extract_bp" not in action_types:
|
||||
return CommandResult(ok=False, status="skipped", error="extract_bp_disabled")
|
||||
|
||||
run, created = await sync_to_async(CommandRun.objects.get_or_create)(
|
||||
profile=profile,
|
||||
trigger_message=trigger,
|
||||
defaults={
|
||||
"user": trigger.user,
|
||||
"status": "running",
|
||||
},
|
||||
)
|
||||
if not created and run.status in {"ok", "running"}:
|
||||
return CommandResult(
|
||||
ok=True,
|
||||
status="ok",
|
||||
payload={"document_id": str(run.result_ref_id or "")},
|
||||
)
|
||||
run.status = "running"
|
||||
run.error = ""
|
||||
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||
|
||||
if trigger.reply_to_id is None:
|
||||
run.status = "failed"
|
||||
run.error = "bp_requires_reply_target"
|
||||
await sync_to_async(run.save)(
|
||||
update_fields=["status", "error", "updated_at"]
|
||||
)
|
||||
return CommandResult(ok=False, status="failed", error=run.error)
|
||||
|
||||
anchor = trigger.reply_to
|
||||
rows = await sync_to_async(list)(
|
||||
Message.objects.filter(
|
||||
user=trigger.user,
|
||||
session=trigger.session,
|
||||
ts__gte=int(anchor.ts or 0),
|
||||
ts__lte=int(trigger.ts or 0),
|
||||
)
|
||||
.order_by("ts")
|
||||
.select_related("session", "session__identifier", "session__identifier__person")
|
||||
)
|
||||
transcript = messages_to_string(
|
||||
rows,
|
||||
author_rewrites={"USER": "Operator", "BOT": "Assistant"},
|
||||
)
|
||||
max_transcript_chars = int(
|
||||
getattr(settings, "BP_MAX_TRANSCRIPT_CHARS", 12000) or 12000
|
||||
)
|
||||
transcript = _clamp_transcript(transcript, max_transcript_chars)
|
||||
default_template = (
|
||||
"Business Plan:\n"
|
||||
"- Objective\n"
|
||||
"- Audience\n"
|
||||
"- Offer\n"
|
||||
"- GTM\n"
|
||||
"- Risks"
|
||||
)
|
||||
template_text = profile.template_text or default_template
|
||||
max_template_chars = int(
|
||||
getattr(settings, "BP_MAX_TEMPLATE_CHARS", 5000) or 5000
|
||||
)
|
||||
template_text = str(template_text or "")[:max_template_chars]
|
||||
ai_obj = await sync_to_async(
|
||||
# Match compose draft/engage lookup behavior exactly.
|
||||
lambda: AI.objects.filter(user=trigger.user).first()
|
||||
)()
|
||||
ai_warning = ""
|
||||
if ai_obj is None:
|
||||
summary = _bp_fallback_markdown(
|
||||
template_text,
|
||||
transcript,
|
||||
"ai_not_configured",
|
||||
)
|
||||
ai_warning = "ai_not_configured"
|
||||
else:
|
||||
prompt = [
|
||||
{"role": "system", "content": _bp_system_prompt()},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Template:\n"
|
||||
f"{template_text}\n\n"
|
||||
"Messages:\n"
|
||||
f"{transcript}"
|
||||
),
|
||||
},
|
||||
]
|
||||
try:
|
||||
summary = str(await ai_runner.run_prompt(prompt, ai_obj) or "").strip()
|
||||
if not summary:
|
||||
raise RuntimeError("empty_ai_response")
|
||||
except Exception as exc:
|
||||
ai_warning = f"bp_ai_failed:{exc}"
|
||||
summary = _bp_fallback_markdown(
|
||||
template_text,
|
||||
transcript,
|
||||
str(exc),
|
||||
)
|
||||
|
||||
document = await sync_to_async(BusinessPlanDocument.objects.create)(
|
||||
user=trigger.user,
|
||||
command_profile=profile,
|
||||
source_service=trigger.source_service or ctx.service,
|
||||
source_channel_identifier=trigger.source_chat_id or ctx.channel_identifier,
|
||||
trigger_message=trigger,
|
||||
anchor_message=anchor,
|
||||
title=f"Business Plan {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
status="draft",
|
||||
content_markdown=summary,
|
||||
structured_payload={"source_message_ids": [str(row.id) for row in rows]},
|
||||
)
|
||||
await sync_to_async(BusinessPlanRevision.objects.create)(
|
||||
document=document,
|
||||
editor_user=trigger.user,
|
||||
content_markdown=summary,
|
||||
structured_payload={"source_message_ids": [str(row.id) for row in rows]},
|
||||
)
|
||||
|
||||
fanout_stats = {"sent_bindings": 0, "failed_bindings": 0}
|
||||
if "post_result" in action_types:
|
||||
fanout_stats = await self._fanout(run, summary)
|
||||
|
||||
if "status_in_source" == profile.visibility_mode:
|
||||
status_text = f"[bp] Generated business plan: {document.title}"
|
||||
if ai_warning:
|
||||
status_text += " (fallback mode)"
|
||||
sent_count = int(fanout_stats.get("sent_bindings") or 0)
|
||||
failed_count = int(fanout_stats.get("failed_bindings") or 0)
|
||||
if sent_count or failed_count:
|
||||
status_text += f" · fanout sent:{sent_count}"
|
||||
if failed_count:
|
||||
status_text += f" failed:{failed_count}"
|
||||
await self._status_message(trigger, status_text)
|
||||
|
||||
run.status = "ok"
|
||||
run.result_ref = document
|
||||
run.error = ai_warning
|
||||
await sync_to_async(run.save)(
|
||||
update_fields=["status", "result_ref", "error", "updated_at"]
|
||||
)
|
||||
return CommandResult(
|
||||
ok=True,
|
||||
status="ok",
|
||||
payload={"document_id": str(document.id)},
|
||||
)
|
||||
16
core/commands/registry.py
Normal file
16
core/commands/registry.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.commands.base import CommandHandler
|
||||
|
||||
_HANDLERS: dict[str, CommandHandler] = {}
|
||||
|
||||
|
||||
def register(handler: CommandHandler):
|
||||
slug = str(getattr(handler, "slug", "") or "").strip().lower()
|
||||
if not slug:
|
||||
raise ValueError("handler slug is required")
|
||||
_HANDLERS[slug] = handler
|
||||
|
||||
|
||||
def get(slug: str) -> CommandHandler | None:
|
||||
return _HANDLERS.get(str(slug or "").strip().lower())
|
||||
Reference in New Issue
Block a user