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.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, Message, ) _BP_SET_RE = re.compile(r"^\s*#bp\s+set#(?P.*)$", re.IGNORECASE | re.DOTALL) _BP_SET_RANGE_RE = re.compile(r"^\s*#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_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 bool(getattr(settings, "BP_SUBCOMMANDS_V1", True)): return True if not trigger: return False if exact_match_only: return body == trigger return trigger in body 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" 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 _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, action_types: set[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) content = plain_text_blob(rows) if not 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) annotation = self._annotation("set_range", len(rows)) 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) annotation = self._annotation("set", 1 if not has_addendum else 2, has_addendum) 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 "post_result" in action_types: fanout_body = f"{doc.content_markdown}\n\n{doc.structured_payload.get('annotation', '')}".strip() fanout_stats = await self._fanout(run, fanout_body) if "status_in_source" == profile.visibility_mode: status_text = ( f"[bp] {doc.structured_payload.get('annotation', '').strip()} " f"Saved as {doc.title}." ).strip() 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 post_status_in_source( trigger_message=trigger, text=status_text, origin_tag=f"bp-status:{trigger.id}", ) 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)}) async def _execute_legacy_ai( self, *, trigger: Message, run: CommandRun, profile, action_types: set[str], ctx: CommandContext, ) -> 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] 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 = 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 "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}" 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 post_status_in_source( trigger_message=trigger, text=status_text, origin_tag=f"bp-status:{trigger.id}", ) 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)}) 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"]) parsed = parse_bp_subcommand(ctx.message_text) if parsed.command and bool(getattr(settings, "BP_SUBCOMMANDS_V1", True)): return await self._execute_set_or_range( trigger=trigger, run=run, profile=profile, action_types=action_types, parsed=parsed, ) return await self._execute_legacy_ai( trigger=trigger, run=run, profile=profile, action_types=action_types, ctx=ctx, )