Implement tasks
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
@@ -8,6 +9,7 @@ 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,
|
||||
@@ -19,6 +21,49 @@ from core.models import (
|
||||
Message,
|
||||
)
|
||||
|
||||
_BP_SET_RE = re.compile(r"^\s*#bp\s+set#(?P<rest>.*)$", 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 (
|
||||
@@ -43,22 +88,6 @@ def _clamp_transcript(transcript: str, max_chars: int) -> str:
|
||||
)
|
||||
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
class BPCommandHandler(CommandHandler):
|
||||
slug = "bp"
|
||||
|
||||
@@ -95,60 +124,8 @@ class BPCommandHandler(CommandHandler):
|
||||
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)(
|
||||
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,
|
||||
@@ -158,105 +135,142 @@ class BPCommandHandler(CommandHandler):
|
||||
.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, operation="command_bp_extract"
|
||||
)
|
||||
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),
|
||||
)
|
||||
|
||||
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 ctx.service,
|
||||
source_channel_identifier=trigger.source_chat_id or ctx.channel_identifier,
|
||||
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=summary,
|
||||
structured_payload={"source_message_ids": [str(row.id) for row in rows]},
|
||||
content_markdown=content,
|
||||
structured_payload=payload,
|
||||
)
|
||||
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]},
|
||||
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}
|
||||
fanout_text = summary
|
||||
if ai_warning:
|
||||
warning_text = str(ai_warning or "").strip()
|
||||
if len(warning_text) > 300:
|
||||
warning_text = warning_text[:297].rstrip() + "..."
|
||||
fanout_text = (
|
||||
"[bp] AI generation failed. Draft document was saved in fallback mode."
|
||||
+ (f"\nReason: {warning_text}" if warning_text else "")
|
||||
)
|
||||
if "post_result" in action_types:
|
||||
fanout_stats = await self._fanout(run, fanout_text)
|
||||
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] Generated business plan: {document.title}"
|
||||
if ai_warning:
|
||||
status_text += " (fallback 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:
|
||||
@@ -270,13 +284,154 @@ class BPCommandHandler(CommandHandler):
|
||||
)
|
||||
|
||||
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"]
|
||||
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"},
|
||||
)
|
||||
return CommandResult(
|
||||
ok=True,
|
||||
status="ok",
|
||||
payload={"document_id": str(document.id)},
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user