Files
GIA/core/commands/handlers/bp.py
2026-03-04 02:19:22 +00:00

651 lines
25 KiB
Python

from __future__ import annotations
import re
import time
from asgiref.sync import sync_to_async
from django.conf import settings
from core.commands.base import CommandContext, CommandHandler, CommandResult
from core.commands.delivery import post_status_in_source, post_to_channel_binding
from core.commands.policies import BP_VARIANT_META, load_variant_policy
from core.messaging import ai as ai_runner
from core.messaging.text_export import plain_text_blob
from core.messaging.utils import messages_to_string
from core.models import (
AI,
BusinessPlanDocument,
BusinessPlanRevision,
CommandAction,
CommandChannelBinding,
CommandRun,
CommandVariantPolicy,
Message,
)
_BP_ROOT_RE = re.compile(r"^\s*(?:\.bp\b|#bp#?)\s*$", re.IGNORECASE)
_BP_SET_RE = re.compile(
r"^\s*(?:\.bp\s+set\b|#bp\s+set#?)(?P<rest>.*)$",
re.IGNORECASE | re.DOTALL,
)
_BP_SET_RANGE_RE = re.compile(
r"^\s*(?:\.bp\s+set\s+range\b|#bp\s+set\s+range#?)(?:.*)$",
re.IGNORECASE | re.DOTALL,
)
class BPParsedCommand(dict):
@property
def command(self) -> str | None:
value = self.get("command")
return str(value) if value else None
@property
def remainder_text(self) -> str:
return str(self.get("remainder_text") or "")
def parse_bp_subcommand(text: str) -> BPParsedCommand:
body = str(text or "")
if _BP_SET_RANGE_RE.match(body):
return BPParsedCommand(command="set_range", remainder_text="")
match = _BP_SET_RE.match(body)
if match:
return BPParsedCommand(command="set", remainder_text=str(match.group("rest") or "").strip())
return BPParsedCommand(command=None, remainder_text="")
def bp_subcommands_enabled() -> bool:
raw = getattr(settings, "BP_SUBCOMMANDS_V1", True)
if raw is None:
return True
return bool(raw)
def bp_trigger_matches(message_text: str, trigger_token: str, exact_match_only: bool) -> bool:
body = str(message_text or "").strip()
trigger = str(trigger_token or "").strip()
parsed = parse_bp_subcommand(body)
if parsed.command and bp_subcommands_enabled():
return True
if _BP_ROOT_RE.match(body):
return True
if not trigger:
return False
if exact_match_only:
return body.lower() == trigger.lower()
return trigger.lower() in body.lower()
def bp_reply_is_optional_for_trigger(message_text: str) -> bool:
parsed = parse_bp_subcommand(message_text)
return parsed.command == "set"
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()
)
class BPCommandHandler(CommandHandler):
slug = "bp"
def _variant_key_for_text(self, text: str) -> str:
parsed = parse_bp_subcommand(text)
if parsed.command == "set":
return "bp_set"
if parsed.command == "set_range":
return "bp_set_range"
return "bp"
def _variant_display_name(self, variant_key: str) -> str:
meta = BP_VARIANT_META.get(str(variant_key or "").strip(), {})
return str(meta.get("name") or variant_key or "bp")
async def _effective_policy(
self,
*,
profile,
variant_key: str,
action_types: set[str],
) -> dict:
policy = await sync_to_async(load_variant_policy)(profile, variant_key)
if isinstance(policy, CommandVariantPolicy):
return {
"enabled": bool(policy.enabled),
"generation_mode": str(policy.generation_mode or "verbatim"),
"send_plan_to_egress": bool(policy.send_plan_to_egress)
and ("post_result" in action_types),
"send_status_to_source": bool(policy.send_status_to_source)
or str(profile.visibility_mode or "") == "status_in_source",
"send_status_to_egress": bool(policy.send_status_to_egress),
"store_document": bool(getattr(policy, "store_document", True)),
}
return {
"enabled": True,
"generation_mode": "ai" if variant_key == "bp" else "verbatim",
"send_plan_to_egress": "post_result" in action_types,
"send_status_to_source": str(profile.visibility_mode or "") == "status_in_source",
"send_status_to_egress": False,
"store_document": True,
}
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:
ok = await post_to_channel_binding(
trigger_message=trigger,
binding_service=binding.service,
binding_channel_identifier=binding.channel_identifier,
text=text,
origin_tag=f"bp:{run.id}",
command_slug=self.slug,
)
if ok:
sent_bindings += 1
else:
failed_bindings += 1
return {"sent_bindings": sent_bindings, "failed_bindings": failed_bindings}
async def _fanout_status(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:
ok = await post_to_channel_binding(
trigger_message=trigger,
binding_service=binding.service,
binding_channel_identifier=binding.channel_identifier,
text=text,
origin_tag=f"bp-status-egress:{run.id}",
command_slug=self.slug,
)
if ok:
sent_bindings += 1
else:
failed_bindings += 1
return {"sent_bindings": sent_bindings, "failed_bindings": failed_bindings}
async def _load_window(self, trigger: Message, anchor: Message) -> list[Message]:
return 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")
)
def _annotation(self, mode: str, message_count: int, has_addendum: bool = False) -> str:
if mode == "set" and has_addendum:
return "Generated from 1 message + 1 addendum."
if message_count == 1:
return "Generated from 1 message."
return f"Generated from {int(message_count)} messages."
async def _persist_document(
self,
*,
run: CommandRun,
trigger: Message,
profile,
anchor: Message | None,
content: str,
mode: str,
source_message_ids: list[str],
annotation: str,
) -> BusinessPlanDocument:
payload = {
"mode": mode,
"source_message_ids": list(source_message_ids),
"annotation": annotation,
}
document = await sync_to_async(BusinessPlanDocument.objects.create)(
user=trigger.user,
command_profile=profile,
source_service=trigger.source_service or "web",
source_channel_identifier=trigger.source_chat_id or "",
trigger_message=trigger,
anchor_message=anchor,
title=f"Business Plan {time.strftime('%Y-%m-%d %H:%M:%S')}",
status="draft",
content_markdown=content,
structured_payload=payload,
)
await sync_to_async(BusinessPlanRevision.objects.create)(
document=document,
editor_user=trigger.user,
content_markdown=content,
structured_payload=payload,
)
run.result_ref = document
await sync_to_async(run.save)(update_fields=["result_ref", "updated_at"])
return document
async def _execute_set_or_range(
self,
*,
trigger: Message,
run: CommandRun,
profile,
policy: dict,
variant_key: str,
parsed: BPParsedCommand,
) -> CommandResult:
mode = str(parsed.command or "")
remainder = parsed.remainder_text
anchor = trigger.reply_to
if mode == "set_range":
if anchor is None:
run.status = "failed"
run.error = "bp_set_range_requires_reply_target"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(ok=False, status="failed", error=run.error)
rows = await self._load_window(trigger, anchor)
deterministic_content = plain_text_blob(rows)
if not deterministic_content.strip():
run.status = "failed"
run.error = "bp_set_range_empty_content"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(ok=False, status="failed", error=run.error)
if str(policy.get("generation_mode") or "verbatim") == "ai":
ai_obj = await sync_to_async(lambda: AI.objects.filter(user=trigger.user).first())()
if ai_obj is None:
run.status = "failed"
run.error = "ai_not_configured"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(ok=False, status="failed", error=run.error)
prompt = [
{
"role": "system",
"content": (
"Transform source chat text into a structured business plan in markdown. "
"Do not reference any user template."
),
},
{"role": "user", "content": deterministic_content},
]
try:
content = str(
await ai_runner.run_prompt(
prompt,
ai_obj,
operation="command_bp_set_range_extract",
)
or ""
).strip()
except Exception as exc:
run.status = "failed"
run.error = f"bp_ai_failed:{exc}"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(ok=False, status="failed", error=run.error)
if not content:
run.status = "failed"
run.error = "empty_ai_response"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(ok=False, status="failed", error=run.error)
else:
content = deterministic_content
annotation = self._annotation("set_range", len(rows))
doc = None
if bool(policy.get("store_document", True)):
doc = await self._persist_document(
run=run,
trigger=trigger,
profile=profile,
anchor=anchor,
content=content,
mode="set_range",
source_message_ids=[str(row.id) for row in rows],
annotation=annotation,
)
elif mode == "set":
source_ids: list[str] = []
if anchor is not None and not remainder:
content = str(anchor.text or "").strip() or "(no text)"
source_ids.append(str(anchor.id))
has_addendum = False
elif anchor is not None and remainder:
base = str(anchor.text or "").strip() or "(no text)"
content = (
f"{base}\n"
"--- Addendum (newer message text) ---\n"
f"{remainder}"
)
source_ids.extend([str(anchor.id), str(trigger.id)])
has_addendum = True
elif remainder:
content = remainder
source_ids.append(str(trigger.id))
has_addendum = False
else:
run.status = "failed"
run.error = "bp_set_empty_content"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(ok=False, status="failed", error=run.error)
if str(policy.get("generation_mode") or "verbatim") == "ai":
ai_obj = await sync_to_async(lambda: AI.objects.filter(user=trigger.user).first())()
if ai_obj is None:
run.status = "failed"
run.error = "ai_not_configured"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(ok=False, status="failed", error=run.error)
prompt = [
{
"role": "system",
"content": (
"Transform source chat text into a structured business plan in markdown. "
"Do not reference any user template."
),
},
{"role": "user", "content": content},
]
try:
ai_content = str(
await ai_runner.run_prompt(
prompt,
ai_obj,
operation="command_bp_set_extract",
)
or ""
).strip()
except Exception as exc:
run.status = "failed"
run.error = f"bp_ai_failed:{exc}"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(ok=False, status="failed", error=run.error)
if not ai_content:
run.status = "failed"
run.error = "empty_ai_response"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(ok=False, status="failed", error=run.error)
content = ai_content
annotation = self._annotation("set", 1 if not has_addendum else 2, has_addendum)
doc = None
if bool(policy.get("store_document", True)):
doc = await self._persist_document(
run=run,
trigger=trigger,
profile=profile,
anchor=anchor,
content=content,
mode="set",
source_message_ids=source_ids,
annotation=annotation,
)
else:
run.status = "failed"
run.error = "bp_unknown_subcommand"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(ok=False, status="failed", error=run.error)
fanout_stats = {"sent_bindings": 0, "failed_bindings": 0}
if bool(policy.get("send_plan_to_egress")):
fanout_body = f"{content}\n\n{annotation}".strip()
fanout_stats = await self._fanout(run, fanout_body)
sent_count = int(fanout_stats.get("sent_bindings") or 0)
failed_count = int(fanout_stats.get("failed_bindings") or 0)
status_text = (
f"[bp:{self._variant_display_name(variant_key)}:{policy.get('generation_mode')}] "
f"{annotation.strip()} "
f"{'Saved as ' + doc.title + ' · ' if doc else 'Not saved (store_document disabled) · '}"
f"fanout sent:{sent_count}"
).strip()
if failed_count:
status_text += f" failed:{failed_count}"
if bool(policy.get("send_status_to_source")):
await post_status_in_source(
trigger_message=trigger,
text=status_text,
origin_tag=f"bp-status:{trigger.id}",
)
if bool(policy.get("send_status_to_egress")):
await self._fanout_status(run, status_text)
run.status = "ok"
run.error = ""
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(
ok=True,
status="ok",
payload={"document_id": str(doc.id) if doc else ""},
)
async def _execute_legacy_ai(
self,
*,
trigger: Message,
run: CommandRun,
profile,
policy: dict,
variant_key: str,
) -> CommandResult:
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 self._load_window(trigger, anchor)
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]
generation_mode = str(policy.get("generation_mode") or "ai")
if generation_mode == "verbatim":
summary = plain_text_blob(rows)
if not summary.strip():
run.status = "failed"
run.error = "bp_verbatim_empty_content"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(ok=False, status="failed", error=run.error)
else:
ai_obj = await sync_to_async(lambda: AI.objects.filter(user=trigger.user).first())()
if ai_obj is None:
run.status = "failed"
run.error = "ai_not_configured"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(ok=False, status="failed", error=run.error)
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, operation="command_bp_extract") or "").strip()
if not summary:
raise RuntimeError("empty_ai_response")
except Exception as exc:
run.status = "failed"
run.error = f"bp_ai_failed:{exc}"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(ok=False, status="failed", error=run.error)
annotation = self._annotation("legacy", len(rows))
document = None
if bool(policy.get("store_document", True)):
document = await self._persist_document(
run=run,
trigger=trigger,
profile=profile,
anchor=anchor,
content=summary,
mode="legacy_ai",
source_message_ids=[str(row.id) for row in rows],
annotation=annotation,
)
fanout_stats = {"sent_bindings": 0, "failed_bindings": 0}
if bool(policy.get("send_plan_to_egress")):
fanout_stats = await self._fanout(run, summary)
sent_count = int(fanout_stats.get("sent_bindings") or 0)
failed_count = int(fanout_stats.get("failed_bindings") or 0)
status_text = (
f"[bp:{self._variant_display_name(variant_key)}:{generation_mode}] "
f"Generated business plan: "
f"{document.title if document else 'not saved (store_document disabled)'} "
f"· fanout sent:{sent_count}"
)
if failed_count:
status_text += f" failed:{failed_count}"
if bool(policy.get("send_status_to_source")):
await post_status_in_source(
trigger_message=trigger,
text=status_text,
origin_tag=f"bp-status:{trigger.id}",
)
if bool(policy.get("send_status_to_egress")):
await self._fanout_status(run, status_text)
run.status = "ok"
run.error = ""
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(
ok=True,
status="ok",
payload={"document_id": str(document.id) if document else ""},
)
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"])
variant_key = self._variant_key_for_text(ctx.message_text)
policy = await self._effective_policy(
profile=profile,
variant_key=variant_key,
action_types=action_types,
)
if not bool(policy.get("enabled")):
run.status = "skipped"
run.error = f"variant_disabled:{variant_key}"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
return CommandResult(ok=False, status="skipped", error=run.error)
parsed = parse_bp_subcommand(ctx.message_text)
if parsed.command and bp_subcommands_enabled():
return await self._execute_set_or_range(
trigger=trigger,
run=run,
profile=profile,
policy=policy,
variant_key=variant_key,
parsed=parsed,
)
return await self._execute_legacy_ai(
trigger=trigger,
run=run,
profile=profile,
policy=policy,
variant_key=variant_key,
)