Implement tasks
This commit is contained in:
36
app/urls.py
36
app/urls.py
@@ -37,6 +37,7 @@ from core.views import (
|
||||
queues,
|
||||
sessions,
|
||||
signal,
|
||||
tasks,
|
||||
system,
|
||||
whatsapp,
|
||||
workspace,
|
||||
@@ -266,6 +267,41 @@ urlpatterns = [
|
||||
compose.ComposeContactCreateAll.as_view(),
|
||||
name="compose_contact_create_all",
|
||||
),
|
||||
path(
|
||||
"compose/answer-suggestion/send/",
|
||||
tasks.AnswerSuggestionSend.as_view(),
|
||||
name="compose_answer_suggestion_send",
|
||||
),
|
||||
path(
|
||||
"tasks/",
|
||||
tasks.TasksHub.as_view(),
|
||||
name="tasks_hub",
|
||||
),
|
||||
path(
|
||||
"tasks/projects/<str:project_id>/",
|
||||
tasks.TaskProjectDetail.as_view(),
|
||||
name="tasks_project",
|
||||
),
|
||||
path(
|
||||
"tasks/epics/<str:epic_id>/",
|
||||
tasks.TaskEpicDetail.as_view(),
|
||||
name="tasks_epic",
|
||||
),
|
||||
path(
|
||||
"tasks/groups/<str:service>/<path:identifier>/",
|
||||
tasks.TaskGroupDetail.as_view(),
|
||||
name="tasks_group",
|
||||
),
|
||||
path(
|
||||
"tasks/task/<str:task_id>/",
|
||||
tasks.TaskDetail.as_view(),
|
||||
name="tasks_task",
|
||||
),
|
||||
path(
|
||||
"settings/tasks/",
|
||||
tasks.TaskSettings.as_view(),
|
||||
name="tasks_settings",
|
||||
),
|
||||
# AIs
|
||||
path(
|
||||
"ai/workspace/",
|
||||
|
||||
0
core/assist/__init__.py
Normal file
0
core/assist/__init__.py
Normal file
13
core/assist/engine.py
Normal file
13
core/assist/engine.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.assist.repeat_answer import find_repeat_answer, learn_from_message
|
||||
from core.models import Message
|
||||
from core.tasks.engine import process_inbound_task_intelligence
|
||||
|
||||
|
||||
async def process_inbound_assist(message: Message) -> None:
|
||||
if message is None:
|
||||
return
|
||||
await learn_from_message(message)
|
||||
await find_repeat_answer(message.user, message)
|
||||
await process_inbound_task_intelligence(message)
|
||||
136
core/assist/repeat_answer.py
Normal file
136
core/assist/repeat_answer.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.utils import timezone
|
||||
|
||||
from core.models import AnswerMemory, AnswerSuggestionEvent, Message
|
||||
|
||||
_WORD_RE = re.compile(r"[^a-z0-9\s]+", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RepeatAnswerSuggestion:
|
||||
answer_memory_id: str
|
||||
answer_text: str
|
||||
score: float
|
||||
|
||||
|
||||
def _normalize_question(text: str) -> str:
|
||||
body = str(text or "").strip().lower()
|
||||
body = _WORD_RE.sub(" ", body)
|
||||
body = re.sub(r"\s+", " ", body).strip()
|
||||
return body
|
||||
|
||||
|
||||
def _fingerprint(text: str) -> str:
|
||||
norm = _normalize_question(text)
|
||||
if not norm:
|
||||
return ""
|
||||
return hashlib.sha1(norm.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _is_question(text: str) -> bool:
|
||||
body = str(text or "").strip()
|
||||
if not body:
|
||||
return False
|
||||
low = body.lower()
|
||||
return body.endswith("?") or low.startswith(("what", "why", "how", "when", "where", "who", "can ", "do ", "did ", "is ", "are "))
|
||||
|
||||
|
||||
def _is_group_channel(message: Message) -> bool:
|
||||
channel = str(getattr(message, "source_chat_id", "") or "").strip().lower()
|
||||
if channel.endswith("@g.us"):
|
||||
return True
|
||||
return str(getattr(message, "source_service", "") or "").strip().lower() == "xmpp" and "conference." in channel
|
||||
|
||||
|
||||
async def learn_from_message(message: Message) -> None:
|
||||
if message is None:
|
||||
return
|
||||
text = str(message.text or "").strip()
|
||||
if not text:
|
||||
return
|
||||
if dict(message.message_meta or {}).get("origin_tag"):
|
||||
return
|
||||
|
||||
# Build memory by linking obvious reply answers to prior questions.
|
||||
if message.reply_to_id and message.reply_to:
|
||||
q_text = str(message.reply_to.text or "").strip()
|
||||
if _is_question(q_text):
|
||||
fp = _fingerprint(q_text)
|
||||
if fp:
|
||||
await sync_to_async(AnswerMemory.objects.create)(
|
||||
user=message.user,
|
||||
service=message.source_service or "web",
|
||||
channel_identifier=message.source_chat_id or "",
|
||||
question_fingerprint=fp,
|
||||
question_text=q_text,
|
||||
answer_message=message,
|
||||
answer_text=text,
|
||||
confidence_meta={"source": "reply_pair"},
|
||||
)
|
||||
|
||||
|
||||
async def find_repeat_answer(user, message: Message) -> RepeatAnswerSuggestion | None:
|
||||
if message is None:
|
||||
return None
|
||||
if not _is_group_channel(message):
|
||||
return None
|
||||
if dict(message.message_meta or {}).get("origin_tag"):
|
||||
return None
|
||||
text = str(message.text or "").strip()
|
||||
if not _is_question(text):
|
||||
return None
|
||||
|
||||
fp = _fingerprint(text)
|
||||
if not fp:
|
||||
return None
|
||||
|
||||
# channel cooldown for repeated suggestions in short windows
|
||||
cooldown_cutoff = timezone.now() - timezone.timedelta(minutes=3)
|
||||
cooldown_exists = await sync_to_async(
|
||||
lambda: AnswerSuggestionEvent.objects.filter(
|
||||
user=user,
|
||||
message__source_service=message.source_service,
|
||||
message__source_chat_id=message.source_chat_id,
|
||||
status="suggested",
|
||||
created_at__gte=cooldown_cutoff,
|
||||
).exists()
|
||||
)()
|
||||
if cooldown_exists:
|
||||
return None
|
||||
|
||||
memory = await sync_to_async(
|
||||
lambda: AnswerMemory.objects.filter(
|
||||
user=user,
|
||||
service=message.source_service or "web",
|
||||
channel_identifier=message.source_chat_id or "",
|
||||
question_fingerprint=fp,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)()
|
||||
if not memory:
|
||||
return None
|
||||
|
||||
answer = str(memory.answer_text or "").strip()
|
||||
if not answer:
|
||||
return None
|
||||
|
||||
score = 0.99
|
||||
await sync_to_async(AnswerSuggestionEvent.objects.create)(
|
||||
user=user,
|
||||
message=message,
|
||||
status="suggested",
|
||||
candidate_answer=memory,
|
||||
score=score,
|
||||
)
|
||||
return RepeatAnswerSuggestion(
|
||||
answer_memory_id=str(memory.id),
|
||||
answer_text=answer,
|
||||
score=score,
|
||||
)
|
||||
@@ -1,9 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
|
||||
from core.commands.base import CommandContext, CommandResult
|
||||
from core.commands.handlers.bp import BPCommandHandler
|
||||
from core.commands.handlers.bp import (
|
||||
BPCommandHandler,
|
||||
bp_reply_is_optional_for_trigger,
|
||||
bp_trigger_matches,
|
||||
)
|
||||
from core.commands.registry import get as get_handler
|
||||
from core.commands.registry import register
|
||||
from core.messaging.reply_sync import is_mirrored_origin
|
||||
@@ -86,6 +91,12 @@ async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]:
|
||||
|
||||
|
||||
def _matches_trigger(profile: CommandProfile, text: str) -> bool:
|
||||
if profile.slug == "bp" and bool(getattr(settings, "BP_SUBCOMMANDS_V1", True)):
|
||||
return bp_trigger_matches(
|
||||
message_text=text,
|
||||
trigger_token=profile.trigger_token,
|
||||
exact_match_only=profile.exact_match_only,
|
||||
)
|
||||
body = str(text or "").strip()
|
||||
trigger = str(profile.trigger_token or "").strip()
|
||||
if not trigger:
|
||||
@@ -111,15 +122,22 @@ async def process_inbound_message(ctx: CommandContext) -> list[CommandResult]:
|
||||
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},
|
||||
if (
|
||||
profile.slug == "bp"
|
||||
and bool(getattr(settings, "BP_SUBCOMMANDS_V1", True))
|
||||
and bp_reply_is_optional_for_trigger(ctx.message_text)
|
||||
):
|
||||
pass
|
||||
else:
|
||||
results.append(
|
||||
CommandResult(
|
||||
ok=False,
|
||||
status="skipped",
|
||||
error="reply_required",
|
||||
payload={"profile": profile.slug},
|
||||
)
|
||||
)
|
||||
)
|
||||
continue
|
||||
continue
|
||||
handler = get_handler(profile.slug)
|
||||
if handler is None:
|
||||
results.append(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
18
core/messaging/text_export.py
Normal file
18
core/messaging/text_export.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from core.models import Message
|
||||
|
||||
|
||||
def normalize_message_text(message: Message) -> str:
|
||||
text = str(getattr(message, "text", "") or "").strip()
|
||||
return text or "(no text)"
|
||||
|
||||
|
||||
def plain_text_lines(messages: Iterable[Message]) -> list[str]:
|
||||
return [normalize_message_text(message) for message in list(messages)]
|
||||
|
||||
|
||||
def plain_text_blob(messages: Iterable[Message]) -> str:
|
||||
return "\n".join(plain_text_lines(messages))
|
||||
@@ -0,0 +1,341 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-02 11:55
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0028_airunlog'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AnswerMemory',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('service', models.CharField(choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram'), ('web', 'Web')], max_length=255)),
|
||||
('channel_identifier', models.CharField(max_length=255)),
|
||||
('question_fingerprint', models.CharField(max_length=128)),
|
||||
('question_text', models.TextField(blank=True, default='')),
|
||||
('answer_text', models.TextField(blank=True, default='')),
|
||||
('confidence_meta', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AnswerSuggestionEvent',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('status', models.CharField(choices=[('suggested', 'Suggested'), ('accepted', 'Accepted'), ('dismissed', 'Dismissed')], default='suggested', max_length=32)),
|
||||
('score', models.FloatField(default=0.0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ChatTaskSource',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('service', models.CharField(choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram'), ('web', 'Web')], max_length=255)),
|
||||
('channel_identifier', models.CharField(max_length=255)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DerivedTask',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('source_service', models.CharField(choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram'), ('web', 'Web')], max_length=255)),
|
||||
('source_channel', models.CharField(max_length=255)),
|
||||
('reference_code', models.CharField(blank=True, default='', max_length=64)),
|
||||
('external_key', models.CharField(blank=True, default='', max_length=255)),
|
||||
('status_snapshot', models.CharField(blank=True, default='open', max_length=64)),
|
||||
('immutable_payload', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DerivedTaskEvent',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('event_type', models.CharField(choices=[('created', 'Created'), ('progress', 'Progress'), ('completion_marked', 'Completion Marked'), ('synced', 'Synced'), ('sync_failed', 'Sync Failed'), ('parse_warning', 'Parse Warning')], max_length=32)),
|
||||
('actor_identifier', models.CharField(blank=True, default='', max_length=255)),
|
||||
('payload', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ExternalSyncEvent',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('provider', models.CharField(default='mock', max_length=64)),
|
||||
('idempotency_key', models.CharField(blank=True, default='', max_length=255)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('ok', 'OK'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=32)),
|
||||
('payload', models.JSONField(blank=True, default=dict)),
|
||||
('error', models.TextField(blank=True, default='')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TaskCompletionPattern',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('phrase', models.CharField(max_length=64)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('position', models.PositiveIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TaskEpic',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('external_key', models.CharField(blank=True, default='', max_length=255)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('settings', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TaskProject',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('external_key', models.CharField(blank=True, default='', max_length=255)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('settings', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TaskProviderConfig',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('provider', models.CharField(default='mock', max_length=64)),
|
||||
('enabled', models.BooleanField(default=False)),
|
||||
('settings', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='airunlog',
|
||||
new_name='core_airunl_user_id_13b24a_idx',
|
||||
old_name='core_airunl_user_id_6f4700_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='airunlog',
|
||||
new_name='core_airunl_user_id_678025_idx',
|
||||
old_name='core_airunl_user_id_b4486e_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='airunlog',
|
||||
new_name='core_airunl_user_id_55c2d4_idx',
|
||||
old_name='core_airunl_user_id_4f0f5e_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='airunlog',
|
||||
new_name='core_airunl_user_id_bef024_idx',
|
||||
old_name='core_airunl_user_id_953bff_idx',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='answermemory',
|
||||
name='answer_message',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='answer_memory_rows', to='core.message'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='answermemory',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answer_memory', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='answersuggestionevent',
|
||||
name='candidate_answer',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='suggestion_events', to='core.answermemory'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='answersuggestionevent',
|
||||
name='message',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answer_suggestion_events', to='core.message'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='answersuggestionevent',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answer_suggestion_events', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chattasksource',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_task_sources', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='derivedtask',
|
||||
name='origin_message',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='derived_task_origins', to='core.message'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='derivedtask',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='derived_tasks', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='derivedtaskevent',
|
||||
name='source_message',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='derived_task_events', to='core.message'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='derivedtaskevent',
|
||||
name='task',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='core.derivedtask'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='externalsyncevent',
|
||||
name='task',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='external_sync_events', to='core.derivedtask'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='externalsyncevent',
|
||||
name='task_event',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='external_sync_events', to='core.derivedtaskevent'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='externalsyncevent',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_sync_events', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='taskcompletionpattern',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_completion_patterns', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='derivedtask',
|
||||
name='epic',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='derived_tasks', to='core.taskepic'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chattasksource',
|
||||
name='epic',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='chat_sources', to='core.taskepic'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='taskproject',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_projects', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='taskepic',
|
||||
name='project',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='epics', to='core.taskproject'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='derivedtask',
|
||||
name='project',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='derived_tasks', to='core.taskproject'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chattasksource',
|
||||
name='project',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_sources', to='core.taskproject'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='taskproviderconfig',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_provider_configs', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='answermemory',
|
||||
index=models.Index(fields=['user', 'service', 'channel_identifier', 'created_at'], name='core_answer_user_id_b88ba6_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='answermemory',
|
||||
index=models.Index(fields=['user', 'question_fingerprint', 'created_at'], name='core_answer_user_id_9353c7_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='answersuggestionevent',
|
||||
index=models.Index(fields=['user', 'status', 'created_at'], name='core_answer_user_id_05d0f9_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='answersuggestionevent',
|
||||
index=models.Index(fields=['message', 'status'], name='core_answer_message_1cb119_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='derivedtaskevent',
|
||||
index=models.Index(fields=['task', 'created_at'], name='core_derive_task_id_897ae5_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='derivedtaskevent',
|
||||
index=models.Index(fields=['event_type', 'created_at'], name='core_derive_event_t_1cf04b_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='externalsyncevent',
|
||||
index=models.Index(fields=['user', 'provider', 'status', 'updated_at'], name='core_extern_user_id_e71276_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='externalsyncevent',
|
||||
index=models.Index(fields=['idempotency_key'], name='core_extern_idempot_dce064_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='taskcompletionpattern',
|
||||
index=models.Index(fields=['user', 'enabled', 'position'], name='core_taskco_user_id_0c1b5e_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='taskcompletionpattern',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'phrase'), name='unique_task_completion_phrase_per_user'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='taskproject',
|
||||
index=models.Index(fields=['user', 'active', 'updated_at'], name='core_taskpr_user_id_4f8472_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='taskproject',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'name'), name='unique_task_project_name_per_user'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='taskepic',
|
||||
index=models.Index(fields=['project', 'active', 'updated_at'], name='core_taskep_project_ea76c3_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='taskepic',
|
||||
constraint=models.UniqueConstraint(fields=('project', 'name'), name='unique_task_epic_name_per_project'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='derivedtask',
|
||||
index=models.Index(fields=['user', 'project', 'created_at'], name='core_derive_user_id_a98675_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='derivedtask',
|
||||
index=models.Index(fields=['user', 'source_service', 'source_channel'], name='core_derive_user_id_aaa167_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='derivedtask',
|
||||
index=models.Index(fields=['user', 'reference_code'], name='core_derive_user_id_d06303_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='chattasksource',
|
||||
index=models.Index(fields=['user', 'service', 'channel_identifier', 'enabled'], name='core_chatta_user_id_01f271_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='chattasksource',
|
||||
index=models.Index(fields=['project', 'enabled'], name='core_chatta_project_826bab_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='taskproviderconfig',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'provider'), name='unique_task_provider_config_per_user'),
|
||||
),
|
||||
]
|
||||
285
core/models.py
285
core/models.py
@@ -1921,6 +1921,291 @@ class TranslationEventLog(models.Model):
|
||||
]
|
||||
|
||||
|
||||
class AnswerMemory(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="answer_memory")
|
||||
service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
|
||||
channel_identifier = models.CharField(max_length=255)
|
||||
question_fingerprint = models.CharField(max_length=128)
|
||||
question_text = models.TextField(blank=True, default="")
|
||||
answer_message = models.ForeignKey(
|
||||
Message,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="answer_memory_rows",
|
||||
)
|
||||
answer_text = models.TextField(blank=True, default="")
|
||||
confidence_meta = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["user", "service", "channel_identifier", "created_at"]),
|
||||
models.Index(fields=["user", "question_fingerprint", "created_at"]),
|
||||
]
|
||||
|
||||
|
||||
class AnswerSuggestionEvent(models.Model):
|
||||
STATUS_CHOICES = (
|
||||
("suggested", "Suggested"),
|
||||
("accepted", "Accepted"),
|
||||
("dismissed", "Dismissed"),
|
||||
)
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="answer_suggestion_events",
|
||||
)
|
||||
message = models.ForeignKey(
|
||||
Message,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="answer_suggestion_events",
|
||||
)
|
||||
status = models.CharField(max_length=32, choices=STATUS_CHOICES, default="suggested")
|
||||
candidate_answer = models.ForeignKey(
|
||||
AnswerMemory,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="suggestion_events",
|
||||
)
|
||||
score = models.FloatField(default=0.0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["user", "status", "created_at"]),
|
||||
models.Index(fields=["message", "status"]),
|
||||
]
|
||||
|
||||
|
||||
class TaskProject(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_projects")
|
||||
name = models.CharField(max_length=255)
|
||||
external_key = models.CharField(max_length=255, blank=True, default="")
|
||||
active = models.BooleanField(default=True)
|
||||
settings = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "name"],
|
||||
name="unique_task_project_name_per_user",
|
||||
)
|
||||
]
|
||||
indexes = [models.Index(fields=["user", "active", "updated_at"])]
|
||||
|
||||
|
||||
class TaskEpic(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
project = models.ForeignKey(
|
||||
TaskProject,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="epics",
|
||||
)
|
||||
name = models.CharField(max_length=255)
|
||||
external_key = models.CharField(max_length=255, blank=True, default="")
|
||||
active = models.BooleanField(default=True)
|
||||
settings = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["project", "name"],
|
||||
name="unique_task_epic_name_per_project",
|
||||
)
|
||||
]
|
||||
indexes = [models.Index(fields=["project", "active", "updated_at"])]
|
||||
|
||||
|
||||
class ChatTaskSource(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="chat_task_sources")
|
||||
service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
|
||||
channel_identifier = models.CharField(max_length=255)
|
||||
project = models.ForeignKey(
|
||||
TaskProject,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="chat_sources",
|
||||
)
|
||||
epic = models.ForeignKey(
|
||||
TaskEpic,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="chat_sources",
|
||||
)
|
||||
enabled = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["user", "service", "channel_identifier", "enabled"]),
|
||||
models.Index(fields=["project", "enabled"]),
|
||||
]
|
||||
|
||||
|
||||
class DerivedTask(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="derived_tasks")
|
||||
project = models.ForeignKey(
|
||||
TaskProject,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="derived_tasks",
|
||||
)
|
||||
epic = models.ForeignKey(
|
||||
TaskEpic,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="derived_tasks",
|
||||
)
|
||||
title = models.CharField(max_length=255)
|
||||
source_service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
|
||||
source_channel = models.CharField(max_length=255)
|
||||
origin_message = models.ForeignKey(
|
||||
Message,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="derived_task_origins",
|
||||
)
|
||||
reference_code = models.CharField(max_length=64, blank=True, default="")
|
||||
external_key = models.CharField(max_length=255, blank=True, default="")
|
||||
status_snapshot = models.CharField(max_length=64, blank=True, default="open")
|
||||
immutable_payload = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["user", "project", "created_at"]),
|
||||
models.Index(fields=["user", "source_service", "source_channel"]),
|
||||
models.Index(fields=["user", "reference_code"]),
|
||||
]
|
||||
|
||||
|
||||
class DerivedTaskEvent(models.Model):
|
||||
EVENT_CHOICES = (
|
||||
("created", "Created"),
|
||||
("progress", "Progress"),
|
||||
("completion_marked", "Completion Marked"),
|
||||
("synced", "Synced"),
|
||||
("sync_failed", "Sync Failed"),
|
||||
("parse_warning", "Parse Warning"),
|
||||
)
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
task = models.ForeignKey(
|
||||
DerivedTask,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="events",
|
||||
)
|
||||
event_type = models.CharField(max_length=32, choices=EVENT_CHOICES)
|
||||
actor_identifier = models.CharField(max_length=255, blank=True, default="")
|
||||
source_message = models.ForeignKey(
|
||||
Message,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="derived_task_events",
|
||||
)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at", "id"]
|
||||
indexes = [
|
||||
models.Index(fields=["task", "created_at"]),
|
||||
models.Index(fields=["event_type", "created_at"]),
|
||||
]
|
||||
|
||||
|
||||
class ExternalSyncEvent(models.Model):
|
||||
STATUS_CHOICES = (
|
||||
("pending", "Pending"),
|
||||
("ok", "OK"),
|
||||
("failed", "Failed"),
|
||||
("retrying", "Retrying"),
|
||||
)
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="external_sync_events")
|
||||
task = models.ForeignKey(
|
||||
DerivedTask,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="external_sync_events",
|
||||
)
|
||||
task_event = models.ForeignKey(
|
||||
DerivedTaskEvent,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="external_sync_events",
|
||||
)
|
||||
provider = models.CharField(max_length=64, default="mock")
|
||||
idempotency_key = models.CharField(max_length=255, blank=True, default="")
|
||||
status = models.CharField(max_length=32, choices=STATUS_CHOICES, default="pending")
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
error = models.TextField(blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["user", "provider", "status", "updated_at"]),
|
||||
models.Index(fields=["idempotency_key"]),
|
||||
]
|
||||
|
||||
|
||||
class TaskProviderConfig(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_provider_configs")
|
||||
provider = models.CharField(max_length=64, default="mock")
|
||||
enabled = models.BooleanField(default=False)
|
||||
settings = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "provider"],
|
||||
name="unique_task_provider_config_per_user",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class TaskCompletionPattern(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_completion_patterns")
|
||||
phrase = models.CharField(max_length=64)
|
||||
enabled = models.BooleanField(default=True)
|
||||
position = models.PositiveIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "phrase"],
|
||||
name="unique_task_completion_phrase_per_user",
|
||||
)
|
||||
]
|
||||
indexes = [models.Index(fields=["user", "enabled", "position"])]
|
||||
|
||||
|
||||
# class Perms(models.Model):
|
||||
# class Meta:
|
||||
# permissions = (
|
||||
|
||||
@@ -8,6 +8,7 @@ from core.clients.instagram import InstagramClient
|
||||
from core.clients.signal import SignalClient
|
||||
from core.clients.whatsapp import WhatsAppClient
|
||||
from core.clients.xmpp import XMPPClient
|
||||
from core.assist.engine import process_inbound_assist
|
||||
from core.commands.base import CommandContext
|
||||
from core.commands.engine import process_inbound_message
|
||||
from core.messaging import history
|
||||
@@ -122,6 +123,10 @@ class UnifiedRouter(object):
|
||||
await process_inbound_translation(local_message)
|
||||
except Exception as exc:
|
||||
self.log.warning("Translation engine processing failed: %s", exc)
|
||||
try:
|
||||
await process_inbound_assist(local_message)
|
||||
except Exception as exc:
|
||||
self.log.warning("Assist/task processing failed: %s", exc)
|
||||
|
||||
async def _resolve_identifier_objects(self, protocol, identifier):
|
||||
if isinstance(identifier, PersonIdentifier):
|
||||
|
||||
0
core/tasks/__init__.py
Normal file
0
core/tasks/__init__.py
Normal file
341
core/tasks/engine.py
Normal file
341
core/tasks/engine.py
Normal file
@@ -0,0 +1,341 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
|
||||
from core.clients.transport import send_message_raw
|
||||
from core.messaging import ai as ai_runner
|
||||
from core.models import (
|
||||
AI,
|
||||
ChatTaskSource,
|
||||
DerivedTask,
|
||||
DerivedTaskEvent,
|
||||
ExternalSyncEvent,
|
||||
Message,
|
||||
TaskCompletionPattern,
|
||||
TaskProviderConfig,
|
||||
)
|
||||
from core.tasks.providers.mock import get_provider
|
||||
|
||||
_TASK_HINT_RE = re.compile(r"\b(todo|task|action|need to|please)\b", re.IGNORECASE)
|
||||
_COMPLETION_RE = re.compile(r"\b(done|completed|fixed)\s*#([A-Za-z0-9_-]+)\b", re.IGNORECASE)
|
||||
_BALANCED_HINT_RE = re.compile(r"\b(todo|task|action item|action)\b", re.IGNORECASE)
|
||||
_BROAD_HINT_RE = re.compile(r"\b(todo|task|action|need to|please|reminder)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _channel_variants(service: str, channel: str) -> list[str]:
|
||||
value = str(channel or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
variants = [value]
|
||||
if str(service or "").strip().lower() == "whatsapp":
|
||||
bare = value.split("@", 1)[0].strip()
|
||||
if bare and bare not in variants:
|
||||
variants.append(bare)
|
||||
group = f"{bare}@g.us" if bare else ""
|
||||
if group and group not in variants:
|
||||
variants.append(group)
|
||||
return variants
|
||||
|
||||
|
||||
async def _resolve_source_mappings(message: Message) -> list[ChatTaskSource]:
|
||||
variants = _channel_variants(message.source_service or "", message.source_chat_id or "")
|
||||
if not variants:
|
||||
return []
|
||||
return await sync_to_async(list)(
|
||||
ChatTaskSource.objects.filter(
|
||||
user=message.user,
|
||||
enabled=True,
|
||||
service=message.source_service,
|
||||
channel_identifier__in=variants,
|
||||
).select_related("project", "epic")
|
||||
)
|
||||
|
||||
|
||||
def _to_bool(raw, default=False) -> bool:
|
||||
if raw is None:
|
||||
return bool(default)
|
||||
value = str(raw).strip().lower()
|
||||
if value in {"1", "true", "yes", "on", "y"}:
|
||||
return True
|
||||
if value in {"0", "false", "no", "off", "n"}:
|
||||
return False
|
||||
return bool(default)
|
||||
|
||||
|
||||
def _parse_prefixes(raw) -> list[str]:
|
||||
if isinstance(raw, list):
|
||||
values = raw
|
||||
else:
|
||||
values = str(raw or "").split(",")
|
||||
rows = []
|
||||
for row in values:
|
||||
item = str(row or "").strip().lower()
|
||||
if item and item not in rows:
|
||||
rows.append(item)
|
||||
return rows or ["task:", "todo:", "action:"]
|
||||
|
||||
|
||||
def _normalize_flags(raw: dict | None) -> dict:
|
||||
row = dict(raw or {})
|
||||
return {
|
||||
"derive_enabled": _to_bool(row.get("derive_enabled"), True),
|
||||
"match_mode": str(row.get("match_mode") or "balanced").strip().lower() or "balanced",
|
||||
"require_prefix": _to_bool(row.get("require_prefix"), False),
|
||||
"allowed_prefixes": _parse_prefixes(row.get("allowed_prefixes")),
|
||||
"completion_enabled": _to_bool(row.get("completion_enabled"), True),
|
||||
"ai_title_enabled": _to_bool(row.get("ai_title_enabled"), True),
|
||||
"announce_task_id": _to_bool(row.get("announce_task_id"), True),
|
||||
"min_chars": max(1, int(row.get("min_chars") or 3)),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_partial_flags(raw: dict | None) -> dict:
|
||||
row = dict(raw or {})
|
||||
out = {}
|
||||
if "derive_enabled" in row:
|
||||
out["derive_enabled"] = _to_bool(row.get("derive_enabled"), True)
|
||||
if "match_mode" in row:
|
||||
out["match_mode"] = str(row.get("match_mode") or "balanced").strip().lower() or "balanced"
|
||||
if "require_prefix" in row:
|
||||
out["require_prefix"] = _to_bool(row.get("require_prefix"), False)
|
||||
if "allowed_prefixes" in row:
|
||||
out["allowed_prefixes"] = _parse_prefixes(row.get("allowed_prefixes"))
|
||||
if "completion_enabled" in row:
|
||||
out["completion_enabled"] = _to_bool(row.get("completion_enabled"), True)
|
||||
if "ai_title_enabled" in row:
|
||||
out["ai_title_enabled"] = _to_bool(row.get("ai_title_enabled"), True)
|
||||
if "announce_task_id" in row:
|
||||
out["announce_task_id"] = _to_bool(row.get("announce_task_id"), True)
|
||||
if "min_chars" in row:
|
||||
out["min_chars"] = max(1, int(row.get("min_chars") or 3))
|
||||
return out
|
||||
|
||||
|
||||
def _effective_flags(source: ChatTaskSource) -> dict:
|
||||
project_flags = _normalize_flags(getattr(getattr(source, "project", None), "settings", {}) or {})
|
||||
source_flags = _normalize_partial_flags(getattr(source, "settings", {}) or {})
|
||||
merged = dict(project_flags)
|
||||
merged.update(source_flags)
|
||||
return merged
|
||||
|
||||
|
||||
def _is_task_candidate(text: str, flags: dict) -> bool:
|
||||
body = str(text or "").strip()
|
||||
if len(body) < int(flags.get("min_chars") or 1):
|
||||
return False
|
||||
body_lower = body.lower()
|
||||
prefixes = list(flags.get("allowed_prefixes") or [])
|
||||
has_prefix = any(body_lower.startswith(prefix) for prefix in prefixes)
|
||||
if bool(flags.get("require_prefix")) and not has_prefix:
|
||||
return False
|
||||
mode = str(flags.get("match_mode") or "balanced").strip().lower()
|
||||
if mode == "strict":
|
||||
return has_prefix
|
||||
if mode == "broad":
|
||||
return has_prefix or bool(_BROAD_HINT_RE.search(body))
|
||||
return has_prefix or bool(_BALANCED_HINT_RE.search(body))
|
||||
|
||||
|
||||
def _next_reference(user, project) -> str:
|
||||
last = (
|
||||
DerivedTask.objects.filter(user=user, project=project)
|
||||
.exclude(reference_code="")
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if not last:
|
||||
return "1"
|
||||
try:
|
||||
return str(int(str(last.reference_code)) + 1)
|
||||
except Exception:
|
||||
return str(DerivedTask.objects.filter(user=user, project=project).count() + 1)
|
||||
|
||||
|
||||
async def _derive_title(message: Message) -> str:
|
||||
text = str(message.text or "").strip()
|
||||
if not text:
|
||||
return "Untitled task"
|
||||
if not bool(getattr(settings, "TASK_DERIVATION_USE_AI", True)):
|
||||
return text[:255]
|
||||
ai_obj = await sync_to_async(lambda: AI.objects.filter(user=message.user).first())()
|
||||
if not ai_obj:
|
||||
return text[:255]
|
||||
prompt = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Extract one concise actionable task title from the message. Return plain text only.",
|
||||
},
|
||||
{"role": "user", "content": text[:2000]},
|
||||
]
|
||||
try:
|
||||
title = str(await ai_runner.run_prompt(prompt, ai_obj, operation="task_derive_title") or "").strip()
|
||||
except Exception:
|
||||
title = ""
|
||||
return (title or text)[:255]
|
||||
|
||||
|
||||
async def _derive_title_with_flags(message: Message, flags: dict) -> str:
|
||||
if not bool(flags.get("ai_title_enabled", True)):
|
||||
text = str(message.text or "").strip()
|
||||
return (text or "Untitled task")[:255]
|
||||
return await _derive_title(message)
|
||||
|
||||
|
||||
async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: str) -> None:
|
||||
cfg = await sync_to_async(
|
||||
lambda: TaskProviderConfig.objects.filter(user=task.user, enabled=True).order_by("provider").first()
|
||||
)()
|
||||
provider_name = str(getattr(cfg, "provider", "mock") or "mock")
|
||||
provider_settings = dict(getattr(cfg, "settings", {}) or {})
|
||||
provider = get_provider(provider_name)
|
||||
idempotency_key = f"{provider_name}:{task.id}:{event.id}"
|
||||
|
||||
if action == "create":
|
||||
result = provider.create_task(provider_settings, {
|
||||
"task_id": str(task.id),
|
||||
"title": task.title,
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
})
|
||||
elif action == "complete":
|
||||
result = provider.mark_complete(provider_settings, {
|
||||
"task_id": str(task.id),
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
})
|
||||
else:
|
||||
result = provider.append_update(provider_settings, {
|
||||
"task_id": str(task.id),
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"payload": event.payload,
|
||||
})
|
||||
|
||||
status = "ok" if result.ok else "failed"
|
||||
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
|
||||
idempotency_key=idempotency_key,
|
||||
defaults={
|
||||
"user": task.user,
|
||||
"task": task,
|
||||
"task_event": event,
|
||||
"provider": provider_name,
|
||||
"status": status,
|
||||
"payload": dict(result.payload or {}),
|
||||
"error": str(result.error or ""),
|
||||
},
|
||||
)
|
||||
if result.ok and result.external_key and not task.external_key:
|
||||
task.external_key = str(result.external_key)
|
||||
await sync_to_async(task.save)(update_fields=["external_key"])
|
||||
|
||||
|
||||
async def _completion_regex(message: Message) -> re.Pattern:
|
||||
patterns = await sync_to_async(list)(
|
||||
TaskCompletionPattern.objects.filter(user=message.user, enabled=True).order_by("position", "created_at")
|
||||
)
|
||||
phrases = [str(row.phrase or "").strip() for row in patterns if str(row.phrase or "").strip()]
|
||||
if not phrases:
|
||||
phrases = ["done", "completed", "fixed"]
|
||||
return re.compile(r"\\b(?:" + "|".join(re.escape(p) for p in phrases) + r")\\s*#([A-Za-z0-9_-]+)\\b", re.IGNORECASE)
|
||||
|
||||
|
||||
async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
if message is None:
|
||||
return
|
||||
if dict(message.message_meta or {}).get("origin_tag"):
|
||||
return
|
||||
text = str(message.text or "").strip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
sources = await _resolve_source_mappings(message)
|
||||
if not sources:
|
||||
return
|
||||
|
||||
completion_allowed = any(bool(_effective_flags(source).get("completion_enabled")) for source in sources)
|
||||
completion_rx = await _completion_regex(message) if completion_allowed else None
|
||||
marker_match = (completion_rx.search(text) if completion_rx else None) or (_COMPLETION_RE.search(text) if completion_allowed else None)
|
||||
if marker_match:
|
||||
ref_code = str(marker_match.group(marker_match.lastindex or 1) or "").strip()
|
||||
task = await sync_to_async(
|
||||
lambda: DerivedTask.objects.filter(user=message.user, reference_code=ref_code).order_by("-created_at").first()
|
||||
)()
|
||||
if not task:
|
||||
# parser warning event attached to a newly derived placeholder in mapped project
|
||||
source = sources[0]
|
||||
placeholder = await sync_to_async(DerivedTask.objects.create)(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
epic=source.epic,
|
||||
title=f"Unresolved completion marker #{ref_code}",
|
||||
source_service=message.source_service or "web",
|
||||
source_channel=message.source_chat_id or "",
|
||||
origin_message=message,
|
||||
reference_code=ref_code,
|
||||
status_snapshot="warning",
|
||||
immutable_payload={"warning": "completion_marker_unresolved"},
|
||||
)
|
||||
await sync_to_async(DerivedTaskEvent.objects.create)(
|
||||
task=placeholder,
|
||||
event_type="parse_warning",
|
||||
actor_identifier=str(message.sender_uuid or ""),
|
||||
source_message=message,
|
||||
payload={"reason": "completion_marker_unresolved", "marker": ref_code},
|
||||
)
|
||||
return
|
||||
|
||||
task.status_snapshot = "completed"
|
||||
await sync_to_async(task.save)(update_fields=["status_snapshot"])
|
||||
event = await sync_to_async(DerivedTaskEvent.objects.create)(
|
||||
task=task,
|
||||
event_type="completion_marked",
|
||||
actor_identifier=str(message.sender_uuid or ""),
|
||||
source_message=message,
|
||||
payload={"marker": ref_code},
|
||||
)
|
||||
await _emit_sync_event(task, event, "complete")
|
||||
return
|
||||
|
||||
for source in sources:
|
||||
flags = _effective_flags(source)
|
||||
if not bool(flags.get("derive_enabled", True)):
|
||||
continue
|
||||
if not _is_task_candidate(text, flags):
|
||||
continue
|
||||
title = await _derive_title_with_flags(message, flags)
|
||||
reference = await sync_to_async(_next_reference)(message.user, source.project)
|
||||
task = await sync_to_async(DerivedTask.objects.create)(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
epic=source.epic,
|
||||
title=title,
|
||||
source_service=message.source_service or "web",
|
||||
source_channel=message.source_chat_id or "",
|
||||
origin_message=message,
|
||||
reference_code=reference,
|
||||
status_snapshot="open",
|
||||
immutable_payload={"origin_text": text, "flags": flags},
|
||||
)
|
||||
event = await sync_to_async(DerivedTaskEvent.objects.create)(
|
||||
task=task,
|
||||
event_type="created",
|
||||
actor_identifier=str(message.sender_uuid or ""),
|
||||
source_message=message,
|
||||
payload={"origin_text": text},
|
||||
)
|
||||
await _emit_sync_event(task, event, "create")
|
||||
if bool(flags.get("announce_task_id", True)):
|
||||
try:
|
||||
await send_message_raw(
|
||||
message.source_service or "web",
|
||||
message.source_chat_id or "",
|
||||
text=f"[task] Created #{task.reference_code}: {task.title}",
|
||||
attachments=[],
|
||||
metadata={"origin": "task_announce"},
|
||||
)
|
||||
except Exception:
|
||||
# Announcement is best-effort and should not block derivation.
|
||||
pass
|
||||
0
core/tasks/providers/__init__.py
Normal file
0
core/tasks/providers/__init__.py
Normal file
30
core/tasks/providers/base.py
Normal file
30
core/tasks/providers/base.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ProviderResult:
|
||||
ok: bool
|
||||
external_key: str = ""
|
||||
error: str = ""
|
||||
payload: dict | None = None
|
||||
|
||||
|
||||
class TaskProvider:
|
||||
name = "base"
|
||||
|
||||
def healthcheck(self, config: dict) -> ProviderResult:
|
||||
raise NotImplementedError
|
||||
|
||||
def create_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
raise NotImplementedError
|
||||
|
||||
def append_update(self, config: dict, payload: dict) -> ProviderResult:
|
||||
raise NotImplementedError
|
||||
|
||||
def mark_complete(self, config: dict, payload: dict) -> ProviderResult:
|
||||
raise NotImplementedError
|
||||
|
||||
def link_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
raise NotImplementedError
|
||||
34
core/tasks/providers/mock.py
Normal file
34
core/tasks/providers/mock.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .base import ProviderResult, TaskProvider
|
||||
|
||||
|
||||
class MockTaskProvider(TaskProvider):
|
||||
name = "mock"
|
||||
|
||||
def healthcheck(self, config: dict) -> ProviderResult:
|
||||
return ProviderResult(ok=True, payload={"provider": self.name})
|
||||
|
||||
def create_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
ext = str(payload.get("external_key") or "") or f"mock-{int(time.time() * 1000)}"
|
||||
return ProviderResult(ok=True, external_key=ext, payload={"action": "create_task"})
|
||||
|
||||
def append_update(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return ProviderResult(ok=True, external_key=str(payload.get("external_key") or ""), payload={"action": "append_update"})
|
||||
|
||||
def mark_complete(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return ProviderResult(ok=True, external_key=str(payload.get("external_key") or ""), payload={"action": "mark_complete"})
|
||||
|
||||
def link_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return ProviderResult(ok=True, external_key=str(payload.get("external_key") or ""), payload={"action": "link_task"})
|
||||
|
||||
|
||||
PROVIDERS = {
|
||||
"mock": MockTaskProvider(),
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str) -> TaskProvider:
|
||||
return PROVIDERS.get(str(name or "").strip().lower(), PROVIDERS["mock"])
|
||||
@@ -326,6 +326,9 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<a class="navbar-item" href="{% url 'tasks_hub' %}">
|
||||
Tasks
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'ai_workspace' %}">
|
||||
AI
|
||||
</a>
|
||||
|
||||
@@ -52,6 +52,14 @@
|
||||
<li><strong>action post_result</strong>: fan out generated result to enabled egress bindings.</li>
|
||||
<li><strong>position</strong>: execution order (lower runs first).</li>
|
||||
</ul>
|
||||
{% if profile.slug == "bp" %}
|
||||
<p><strong>Supported Triggers (BP)</strong></p>
|
||||
<ul>
|
||||
<li><code>#bp#</code>: primary BP trigger (uses the standard BP extraction flow).</li>
|
||||
<li><code>#bp set#</code>: deterministic no-AI set/update from reply/addendum text.</li>
|
||||
<li><code>#bp set range#</code>: deterministic no-AI set/update from reply-anchor to trigger range.</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="post" style="margin-bottom: 0.75rem;" aria-label="Update command profile {{ profile.name }}">
|
||||
{% csrf_token %}
|
||||
|
||||
34
core/templates/pages/tasks-detail.html
Normal file
34
core/templates/pages/tasks-detail.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="section"><div class="container">
|
||||
<h1 class="title is-4">Task #{{ task.reference_code }}: {{ task.title }}</h1>
|
||||
<p class="subtitle is-6">{{ task.project.name }}{% if task.epic %} / {{ task.epic.name }}{% endif %} · {{ task.status_snapshot }}</p>
|
||||
<div class="buttons"><a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a></div>
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Events</h2>
|
||||
<table class="table is-fullwidth is-size-7">
|
||||
<thead><tr><th>When</th><th>Type</th><th>Actor</th><th>Payload</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in events %}
|
||||
<tr><td>{{ row.created_at }}</td><td>{{ row.event_type }}</td><td>{{ row.actor_identifier }}</td><td><code>{{ row.payload }}</code></td></tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4">No events.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
<article class="box">
|
||||
<h2 class="title is-6">External Sync</h2>
|
||||
<table class="table is-fullwidth is-size-7">
|
||||
<thead><tr><th>When</th><th>Provider</th><th>Status</th><th>Error</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in sync_events %}
|
||||
<tr><td>{{ row.updated_at }}</td><td>{{ row.provider }}</td><td>{{ row.status }}</td><td>{{ row.error }}</td></tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4">No sync events.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
</div></section>
|
||||
{% endblock %}
|
||||
16
core/templates/pages/tasks-epic.html
Normal file
16
core/templates/pages/tasks-epic.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="section"><div class="container">
|
||||
<h1 class="title is-4">Epic: {{ epic.name }}</h1>
|
||||
<div class="buttons"><a class="button is-small is-light" href="{% url 'tasks_project' project_id=epic.project_id %}">Back to project</a></div>
|
||||
<article class="box">
|
||||
<ul>
|
||||
{% for row in tasks %}
|
||||
<li><a href="{% url 'tasks_task' task_id=row.id %}">#{{ row.reference_code }} {{ row.title }}</a></li>
|
||||
{% empty %}
|
||||
<li>No tasks.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
</div></section>
|
||||
{% endblock %}
|
||||
41
core/templates/pages/tasks-group.html
Normal file
41
core/templates/pages/tasks-group.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="section"><div class="container">
|
||||
<h1 class="title is-4">Group Tasks: {{ channel_display_name }}</h1>
|
||||
<p class="subtitle is-6">{{ service_label }} · {{ identifier }}</p>
|
||||
{% if not tasks %}
|
||||
<article class="box">
|
||||
<h2 class="title is-6">No Tasks Yet</h2>
|
||||
<div class="content is-size-7">
|
||||
<p>This group has no derived tasks yet. To start populating this view:</p>
|
||||
<ol>
|
||||
<li>Open <a href="{% url 'tasks_settings' %}?service={{ service }}&identifier={{ identifier|urlencode }}">Task Settings</a> and confirm this chat is mapped under <strong>Group Mapping</strong>.</li>
|
||||
<li>Send task-like messages in this group, for example: <code>task: ship v1</code>, <code>todo: write tests</code>, <code>please review PR</code>.</li>
|
||||
<li>Mark completion explicitly with a phrase + reference, for example: <code>done #12</code>, <code>completed #12</code>, <code>fixed #12</code>.</li>
|
||||
<li>Refresh this page; new derived tasks and events should appear automatically.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Mappings</h2>
|
||||
<ul>
|
||||
{% for row in mappings %}
|
||||
<li>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</li>
|
||||
{% empty %}
|
||||
<li>No mappings for this group.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Derived Tasks</h2>
|
||||
<ul>
|
||||
{% for row in tasks %}
|
||||
<li><a href="{% url 'tasks_task' task_id=row.id %}">#{{ row.reference_code }} {{ row.title }}</a></li>
|
||||
{% empty %}
|
||||
<li>No tasks yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
</div></section>
|
||||
{% endblock %}
|
||||
47
core/templates/pages/tasks-hub.html
Normal file
47
core/templates/pages/tasks-hub.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title is-4">Tasks</h1>
|
||||
<p class="subtitle is-6">Immutable tasks derived from chat activity.</p>
|
||||
<div class="buttons">
|
||||
<a class="button is-small is-link is-light" href="{% url 'tasks_settings' %}">Task Settings</a>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-4">
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Projects</h2>
|
||||
<ul>
|
||||
{% for project in projects %}
|
||||
<li><a href="{% url 'tasks_project' project_id=project.id %}">{{ project.name }}</a> <span class="has-text-grey">({{ project.task_count }})</span></li>
|
||||
{% empty %}
|
||||
<li>No projects yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
<div class="column">
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Recent Derived Tasks</h2>
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<thead><tr><th>Ref</th><th>Title</th><th>Project</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in tasks %}
|
||||
<tr>
|
||||
<td>#{{ row.reference_code }}</td>
|
||||
<td>{{ row.title }}</td>
|
||||
<td>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</td>
|
||||
<td>{{ row.status_snapshot }}</td>
|
||||
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5">No derived tasks yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
27
core/templates/pages/tasks-project.html
Normal file
27
core/templates/pages/tasks-project.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="section"><div class="container">
|
||||
<h1 class="title is-4">Project: {{ project.name }}</h1>
|
||||
<div class="buttons"><a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a></div>
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Epics</h2>
|
||||
<ul>
|
||||
{% for epic in epics %}
|
||||
<li><a href="{% url 'tasks_epic' epic_id=epic.id %}">{{ epic.name }}</a></li>
|
||||
{% empty %}
|
||||
<li>No epics.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Tasks</h2>
|
||||
<ul>
|
||||
{% for row in tasks %}
|
||||
<li><a href="{% url 'tasks_task' task_id=row.id %}">#{{ row.reference_code }} {{ row.title }}</a></li>
|
||||
{% empty %}
|
||||
<li>No tasks.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
</div></section>
|
||||
{% endblock %}
|
||||
386
core/templates/pages/tasks-settings.html
Normal file
386
core/templates/pages/tasks-settings.html
Normal file
@@ -0,0 +1,386 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="container tasks-settings-page">
|
||||
<h1 class="title is-4">Task Settings</h1>
|
||||
<p class="subtitle is-6">Configure task derivation, chat mapping, completion parsing, and external sync behavior.</p>
|
||||
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Setting Definitions</h2>
|
||||
<div class="content is-size-7">
|
||||
<p><strong>Projects</strong>: top-level containers for derived tasks. A single group can map to any project.</p>
|
||||
<p><strong>Epics</strong>: optional sub-grouping inside a project. Use these for parallel workstreams in the same project.</p>
|
||||
<p><strong>Group Mapping</strong>: binds a chat channel (service + channel identifier) to a project and optional epic. Task extraction only runs where mappings exist.</p>
|
||||
<p><strong>Matching Hierarchy</strong>: channel mapping flags override project flags. Project flags are defaults; mapping flags are per-chat precision controls.</p>
|
||||
<p><strong>False-Positive Controls</strong>: defaults are safe: <code>match_mode=strict</code>, <code>require_prefix=true</code>, and prefixes <code>task:</code>/<code>todo:</code>. Freeform matching is off by default.</p>
|
||||
<p><strong>Task ID Announcements</strong>: when enabled, newly derived tasks post an in-chat confirmation containing the new task reference (for example <code>#17</code>).</p>
|
||||
<p><strong>Completion Phrases</strong>: explicit trigger words used to detect completion markers like <code>done #12</code>, <code>completed #12</code>, <code>fixed #12</code>.</p>
|
||||
<p><strong>Provider</strong>: external sync adapter toggle. In current setup, mock provider validates append-only sync flow and retry behavior.</p>
|
||||
<p><strong>Sync Event Log</strong>: audit of provider sync attempts and outcomes. Retry replays the event without mutating immutable task source records.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{% if prefill_service and prefill_identifier %}
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Quick Setup For Current Chat</h2>
|
||||
<p class="help">Prefilled from compose for <code>{{ prefill_service }}</code> · <code>{{ prefill_identifier }}</code>. Create/update project + epic + channel mapping in one step.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="quick_setup">
|
||||
<input type="hidden" name="service" value="{{ prefill_service }}">
|
||||
<input type="hidden" name="channel_identifier" value="{{ prefill_identifier }}">
|
||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<label class="label is-size-7">Project</label>
|
||||
<input class="input is-small" name="project_name" placeholder="Project name">
|
||||
</div>
|
||||
<div class="column">
|
||||
<label class="label is-size-7">Epic (optional)</label>
|
||||
<input class="input is-small" name="epic_name" placeholder="Epic name">
|
||||
</div>
|
||||
<div class="column">
|
||||
<label class="label is-size-7">Match Mode</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="source_match_mode">
|
||||
<option value="strict">strict</option>
|
||||
<option value="balanced">balanced</option>
|
||||
<option value="broad">broad</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<label class="label is-size-7">Prefixes</label>
|
||||
<input class="input is-small" name="source_allowed_prefixes" value="task:,todo:">
|
||||
</div>
|
||||
</div>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" name="source_require_prefix" value="1" checked> Require Prefix</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_completion_enabled" value="1" checked> Completion Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_derive_enabled" value="1" checked> Derivation Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_announce_task_id" value="1" checked> Announce Task ID</label>
|
||||
<button class="button is-small is-link" type="submit" style="margin-left: 0.75rem;">Apply Quick Setup</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
<div class="columns is-multiline tasks-settings-grid">
|
||||
<div class="column is-6">
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Projects</h2>
|
||||
<p class="help">Create project scopes used by group mappings and derived tasks.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="project_create">
|
||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Name</label>
|
||||
<input class="input is-small" name="name" placeholder="Project name">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Match Mode</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="match_mode">
|
||||
<option value="strict">strict</option>
|
||||
<option value="balanced">balanced</option>
|
||||
<option value="broad">broad</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Allowed Prefixes (comma-separated)</label>
|
||||
<input class="input is-small" name="allowed_prefixes" value="task:,todo:">
|
||||
</div>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" name="require_prefix" value="1" checked> Require Prefix</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="derive_enabled" value="1" checked> Derivation Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="completion_enabled" value="1" checked> Completion Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="announce_task_id" value="1" checked> Announce Task ID</label>
|
||||
<button class="button is-small is-link" type="submit">Add Project</button>
|
||||
</form>
|
||||
<ul class="tasks-settings-list">
|
||||
{% for row in projects %}<li>{{ row.name }}</li>{% empty %}<li>No projects.</li>{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Epics</h2>
|
||||
<p class="help">Create project-local epics to refine routing and reporting.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="epic_create">
|
||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Project</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="project_id">
|
||||
{% for p in projects %}<option value="{{ p.id }}">{{ p.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Name</label>
|
||||
<input class="input is-small" name="name" placeholder="Epic name">
|
||||
</div>
|
||||
<button class="button is-small is-link" type="submit">Add Epic</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Group Mapping (Chat -> Project/Epic)</h2>
|
||||
<p class="help">Each mapped group becomes eligible for derived task extraction and completion tracking.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="source_create">
|
||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<label class="label is-size-7">Service</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="service">
|
||||
<option {% if prefill_service == 'web' %}selected{% endif %}>web</option>
|
||||
<option {% if prefill_service == 'xmpp' %}selected{% endif %}>xmpp</option>
|
||||
<option {% if prefill_service == 'signal' %}selected{% endif %}>signal</option>
|
||||
<option {% if prefill_service == 'whatsapp' %}selected{% endif %}>whatsapp</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<label class="label is-size-7">Channel Identifier</label>
|
||||
<input class="input is-small" name="channel_identifier" placeholder="service-native group/channel id" value="{{ prefill_identifier }}">
|
||||
</div>
|
||||
<div class="column">
|
||||
<label class="label is-size-7">Project</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="project_id">
|
||||
{% for p in projects %}<option value="{{ p.id }}">{{ p.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<label class="label is-size-7">Epic (optional)</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="epic_id">
|
||||
<option value="">-</option>
|
||||
{% for e in epics %}<option value="{{ e.id }}">{{ e.project.name }} / {{ e.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<button class="button is-small is-link" type="submit" style="margin-top: 1.8rem;">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<label class="label is-size-7">Match Mode</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="source_match_mode">
|
||||
<option value="strict">strict</option>
|
||||
<option value="balanced">balanced</option>
|
||||
<option value="broad">broad</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<label class="label is-size-7">Allowed Prefixes</label>
|
||||
<input class="input is-small" name="source_allowed_prefixes" value="task:,todo:">
|
||||
</div>
|
||||
<div class="column">
|
||||
<label class="label is-size-7">Min Chars</label>
|
||||
<input class="input is-small" name="source_min_chars" value="3">
|
||||
</div>
|
||||
</div>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" name="source_require_prefix" value="1" checked> Require Prefix</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_derive_enabled" value="1" checked> Derivation Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_completion_enabled" value="1" checked> Completion Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_ai_title_enabled" value="1" checked> AI Title Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_announce_task_id" value="1" checked> Announce Task ID</label>
|
||||
</form>
|
||||
<table class="table is-fullwidth is-size-7">
|
||||
<thead><tr><th>Chat</th><th>Project</th><th>Epic</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in sources %}
|
||||
<tr><td>{{ row.service }} · {{ row.channel_identifier }}</td><td>{{ row.project.name }}</td><td>{{ row.epic.name }}</td></tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3">No mappings.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Project Matching Flags</h2>
|
||||
<p class="help">Project defaults apply to all mapped chats unless channel-level override changes them.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="project_flags_update">
|
||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Project</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="project_id">
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}">{{ p.name }} · mode={{ p.settings_effective.match_mode }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Match Mode</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="match_mode">
|
||||
<option value="strict">strict</option>
|
||||
<option value="balanced">balanced</option>
|
||||
<option value="broad">broad</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Allowed Prefixes</label>
|
||||
<input class="input is-small" name="allowed_prefixes" value="task:,todo:">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Min Chars</label>
|
||||
<input class="input is-small" name="min_chars" value="3">
|
||||
</div>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" name="require_prefix" value="1" checked> Require Prefix</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="derive_enabled" value="1" checked> Derivation Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="completion_enabled" value="1" checked> Completion Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="announce_task_id" value="1" checked> Announce Task ID</label>
|
||||
<button class="button is-small is-link is-light" type="submit" style="margin-left: 0.75rem;">Save Project Flags</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Channel Override Flags</h2>
|
||||
<p class="help">These flags override project defaults for one mapped chat only.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="source_flags_update">
|
||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Mapped Channel</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="source_id">
|
||||
{% for s in sources %}
|
||||
<option value="{{ s.id }}">{{ s.service }} · {{ s.channel_identifier }} · {{ s.project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Match Mode</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="source_match_mode">
|
||||
<option value="strict">strict</option>
|
||||
<option value="balanced">balanced</option>
|
||||
<option value="broad">broad</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Allowed Prefixes</label>
|
||||
<input class="input is-small" name="source_allowed_prefixes" value="task:,todo:">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Min Chars</label>
|
||||
<input class="input is-small" name="source_min_chars" value="3">
|
||||
</div>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" name="source_require_prefix" value="1" checked> Require Prefix</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_derive_enabled" value="1" checked> Derivation Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_completion_enabled" value="1" checked> Completion Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_ai_title_enabled" value="1" checked> AI Title Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_announce_task_id" value="1" checked> Announce Task ID</label>
|
||||
<button class="button is-small is-link is-light" type="submit" style="margin-left: 0.75rem;">Save Channel Flags</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Completion Phrases</h2>
|
||||
<p class="help">Add parser phrases for completion statements followed by a task reference, e.g. <code>done #12</code>.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="pattern_create">
|
||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Phrase</label>
|
||||
<input class="input is-small" name="phrase" placeholder="done">
|
||||
</div>
|
||||
<button class="button is-small is-link" type="submit">Add Phrase</button>
|
||||
</form>
|
||||
<ul class="tasks-settings-list">
|
||||
{% for row in patterns %}<li>{{ row.phrase }}</li>{% empty %}<li>No phrases.</li>{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Provider</h2>
|
||||
<p class="help">Enable/disable external sync adapter and review recent provider event outcomes.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="provider_update">
|
||||
<input type="hidden" name="provider" value="mock">
|
||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
||||
<label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if provider_configs and provider_configs.0.enabled %}checked{% endif %}> Enable mock provider</label>
|
||||
<button class="button is-small is-link is-light" type="submit">Save</button>
|
||||
</form>
|
||||
<table class="table is-fullwidth is-size-7 tasks-settings-table">
|
||||
<thead><tr><th>Updated</th><th>Provider</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in sync_events %}
|
||||
<tr>
|
||||
<td>{{ row.updated_at }}</td>
|
||||
<td>{{ row.provider }}</td>
|
||||
<td>{{ row.status }}</td>
|
||||
<td>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="sync_retry">
|
||||
<input type="hidden" name="event_id" value="{{ row.id }}">
|
||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
||||
<button class="button is-small is-light" type="submit">Retry</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4">No sync events.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.tasks-settings-page .tasks-settings-grid .column > .box {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.tasks-settings-page .tasks-settings-list {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.tasks-settings-page .tasks-settings-table {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -117,6 +117,14 @@
|
||||
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
||||
<span>Quick Insights</span>
|
||||
</button>
|
||||
<a class="button is-light is-rounded" href="{{ tasks_group_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-list-check"></i></span>
|
||||
<span>Tasks</span>
|
||||
</a>
|
||||
<a class="button is-light is-rounded" href="{{ tasks_settings_scoped_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-sitemap"></i></span>
|
||||
<span>Task Setup</span>
|
||||
</a>
|
||||
<a class="button is-light is-rounded" href="{{ ai_workspace_url }}">
|
||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
||||
<span>AI Workspace</span>
|
||||
|
||||
@@ -81,7 +81,7 @@ class BPFallbackTests(TransactionTestCase):
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
def test_bp_falls_back_to_draft_when_ai_fails(self):
|
||||
def test_bp_fails_fast_when_ai_fails(self):
|
||||
anchor = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
@@ -119,11 +119,11 @@ class BPFallbackTests(TransactionTestCase):
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertFalse(result.ok)
|
||||
run = CommandRun.objects.get(trigger_message=trigger, profile=self.profile)
|
||||
self.assertEqual("ok", run.status)
|
||||
self.assertEqual("failed", run.status)
|
||||
self.assertIn("bp_ai_failed", str(run.error))
|
||||
self.assertTrue(BusinessPlanDocument.objects.filter(trigger_message=trigger).exists())
|
||||
self.assertFalse(BusinessPlanDocument.objects.filter(trigger_message=trigger).exists())
|
||||
|
||||
def test_bp_uses_same_ai_selection_order_as_compose(self):
|
||||
AI.objects.create(
|
||||
|
||||
205
core/tests/test_bp_subcommands.py
Normal file
205
core/tests/test_bp_subcommands.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.test import TransactionTestCase, override_settings
|
||||
|
||||
from core.commands.base import CommandContext
|
||||
from core.commands.handlers.bp import BPCommandHandler, parse_bp_subcommand
|
||||
from core.models import (
|
||||
BusinessPlanDocument,
|
||||
ChatSession,
|
||||
CommandAction,
|
||||
CommandChannelBinding,
|
||||
CommandProfile,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
@override_settings(BP_SUBCOMMANDS_V1=True)
|
||||
class BPSubcommandTests(TransactionTestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="bp-sub-user",
|
||||
email="bp-sub@example.com",
|
||||
password="x",
|
||||
)
|
||||
self.person = Person.objects.create(user=self.user, name="Sub Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="whatsapp",
|
||||
identifier="120363402761690215",
|
||||
)
|
||||
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
|
||||
self.profile = CommandProfile.objects.create(
|
||||
user=self.user,
|
||||
slug="bp",
|
||||
name="Business Plan",
|
||||
enabled=True,
|
||||
trigger_token="#bp#",
|
||||
reply_required=True,
|
||||
exact_match_only=True,
|
||||
)
|
||||
CommandChannelBinding.objects.create(
|
||||
profile=self.profile,
|
||||
direction="ingress",
|
||||
service="whatsapp",
|
||||
channel_identifier="120363402761690215",
|
||||
enabled=True,
|
||||
)
|
||||
for action_type, position in (("extract_bp", 0), ("save_document", 1)):
|
||||
CommandAction.objects.create(
|
||||
profile=self.profile,
|
||||
action_type=action_type,
|
||||
enabled=True,
|
||||
position=position,
|
||||
)
|
||||
|
||||
def _ctx(self, trigger: Message, text: str) -> CommandContext:
|
||||
return CommandContext(
|
||||
service="whatsapp",
|
||||
channel_identifier="120363402761690215",
|
||||
message_id=str(trigger.id),
|
||||
user_id=self.user.id,
|
||||
message_text=text,
|
||||
payload={},
|
||||
)
|
||||
|
||||
def test_parser_detects_set_and_remainder(self):
|
||||
parsed = parse_bp_subcommand(" #BP set# addendum text ")
|
||||
self.assertEqual("set", parsed.command)
|
||||
self.assertEqual("addendum text", parsed.remainder_text)
|
||||
|
||||
def test_parser_detects_set_range(self):
|
||||
parsed = parse_bp_subcommand("#bp set range# now")
|
||||
self.assertEqual("set_range", parsed.command)
|
||||
|
||||
def test_set_standalone_uses_remainder_only(self):
|
||||
trigger = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="me",
|
||||
text="#bp set# direct body",
|
||||
ts=1000,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215",
|
||||
)
|
||||
with patch("core.commands.handlers.bp.ai_runner.run_prompt", new=AsyncMock()) as mocked_ai:
|
||||
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text))
|
||||
self.assertTrue(result.ok)
|
||||
mocked_ai.assert_not_awaited()
|
||||
doc = BusinessPlanDocument.objects.get(trigger_message=trigger)
|
||||
self.assertEqual("direct body", doc.content_markdown)
|
||||
self.assertEqual("Generated from 1 message.", doc.structured_payload.get("annotation"))
|
||||
|
||||
def test_set_reply_only_uses_anchor(self):
|
||||
anchor = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="anchor body",
|
||||
ts=1000,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215",
|
||||
)
|
||||
trigger = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="me",
|
||||
text="#bp set#",
|
||||
ts=2000,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215",
|
||||
reply_to=anchor,
|
||||
)
|
||||
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text))
|
||||
self.assertTrue(result.ok)
|
||||
doc = BusinessPlanDocument.objects.get(trigger_message=trigger)
|
||||
self.assertEqual("anchor body", doc.content_markdown)
|
||||
self.assertEqual("Generated from 1 message.", doc.structured_payload.get("annotation"))
|
||||
|
||||
def test_set_reply_plus_addendum_uses_divider(self):
|
||||
anchor = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="base body",
|
||||
ts=1000,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215",
|
||||
)
|
||||
trigger = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="me",
|
||||
text="#bp set# extra text",
|
||||
ts=2000,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215",
|
||||
reply_to=anchor,
|
||||
)
|
||||
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text))
|
||||
self.assertTrue(result.ok)
|
||||
doc = BusinessPlanDocument.objects.get(trigger_message=trigger)
|
||||
self.assertIn("base body", doc.content_markdown)
|
||||
self.assertIn("--- Addendum (newer message text) ---", doc.content_markdown)
|
||||
self.assertIn("extra text", doc.content_markdown)
|
||||
self.assertEqual(
|
||||
"Generated from 1 message + 1 addendum.",
|
||||
doc.structured_payload.get("annotation"),
|
||||
)
|
||||
|
||||
def test_set_range_requires_reply(self):
|
||||
trigger = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="me",
|
||||
text="#bp set range#",
|
||||
ts=3000,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215",
|
||||
)
|
||||
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text))
|
||||
self.assertFalse(result.ok)
|
||||
self.assertEqual("failed", result.status)
|
||||
self.assertEqual("bp_set_range_requires_reply_target", result.error)
|
||||
|
||||
def test_set_range_exports_text_only_lines(self):
|
||||
anchor = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="line 1",
|
||||
ts=1000,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215",
|
||||
)
|
||||
Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="me",
|
||||
text="",
|
||||
ts=1500,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215",
|
||||
)
|
||||
trigger = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="me",
|
||||
text="#bp set range#",
|
||||
ts=2000,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215",
|
||||
reply_to=anchor,
|
||||
)
|
||||
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text))
|
||||
self.assertTrue(result.ok)
|
||||
doc = BusinessPlanDocument.objects.get(trigger_message=trigger)
|
||||
self.assertEqual("line 1\n(no text)\n#bp set range#", doc.content_markdown)
|
||||
self.assertEqual("Generated from 3 messages.", doc.structured_payload.get("annotation"))
|
||||
138
core/tests/test_repeat_answer_and_tasks.py
Normal file
138
core/tests/test_repeat_answer_and_tasks.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from core.assist.repeat_answer import find_repeat_answer, learn_from_message
|
||||
from core.models import (
|
||||
AnswerSuggestionEvent,
|
||||
ChatSession,
|
||||
ChatTaskSource,
|
||||
DerivedTask,
|
||||
DerivedTaskEvent,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
TaskCompletionPattern,
|
||||
TaskProject,
|
||||
User,
|
||||
Message,
|
||||
)
|
||||
from core.tasks.engine import process_inbound_task_intelligence
|
||||
|
||||
|
||||
class RepeatAnswerTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("repeat-user", "repeat@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="Repeat Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="whatsapp",
|
||||
identifier="120363402761690215@g.us",
|
||||
)
|
||||
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
|
||||
|
||||
def test_suggest_only_for_repeated_group_question(self):
|
||||
q1 = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="What is the deploy command?",
|
||||
ts=1000,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
a1 = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="me",
|
||||
text="Use make deploy-prod.",
|
||||
ts=1200,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
reply_to=q1,
|
||||
)
|
||||
async_to_sync(learn_from_message)(a1)
|
||||
|
||||
q2 = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="What is the deploy command?",
|
||||
ts=2000,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
suggestion = async_to_sync(find_repeat_answer)(self.user, q2)
|
||||
self.assertIsNotNone(suggestion)
|
||||
self.assertIn("deploy", suggestion.answer_text.lower())
|
||||
self.assertTrue(
|
||||
AnswerSuggestionEvent.objects.filter(message=q2, status="suggested").exists()
|
||||
)
|
||||
|
||||
|
||||
@override_settings(TASK_DERIVATION_USE_AI=False)
|
||||
class TaskEngineTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("task-user", "task@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="Task Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="whatsapp",
|
||||
identifier="120363402761690215@g.us",
|
||||
)
|
||||
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
|
||||
self.project = TaskProject.objects.create(user=self.user, name="Ops")
|
||||
ChatTaskSource.objects.create(
|
||||
user=self.user,
|
||||
service="whatsapp",
|
||||
channel_identifier="120363402761690215@g.us",
|
||||
project=self.project,
|
||||
enabled=True,
|
||||
)
|
||||
TaskCompletionPattern.objects.create(user=self.user, phrase="done", enabled=True)
|
||||
|
||||
def test_creates_derived_task_on_task_like_message(self):
|
||||
m = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="Task: rotate credentials tonight",
|
||||
ts=1000,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
async_to_sync(process_inbound_task_intelligence)(m)
|
||||
task = DerivedTask.objects.get(origin_message=m)
|
||||
self.assertEqual("open", task.status_snapshot)
|
||||
self.assertTrue(task.reference_code)
|
||||
self.assertTrue(DerivedTaskEvent.objects.filter(task=task, event_type="created").exists())
|
||||
|
||||
def test_marks_completion_from_regex_marker(self):
|
||||
seed = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="task: patch kernel",
|
||||
ts=1000,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
async_to_sync(process_inbound_task_intelligence)(seed)
|
||||
task = DerivedTask.objects.get(origin_message=seed)
|
||||
marker = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text=f"done #{task.reference_code}",
|
||||
ts=1100,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
async_to_sync(process_inbound_task_intelligence)(marker)
|
||||
task.refresh_from_db()
|
||||
self.assertEqual("completed", task.status_snapshot)
|
||||
self.assertTrue(
|
||||
DerivedTaskEvent.objects.filter(task=task, event_type="completion_marked").exists()
|
||||
)
|
||||
@@ -2427,7 +2427,19 @@ def _panel_context(
|
||||
"compose_quick_insights_url": reverse("compose_quick_insights"),
|
||||
"compose_history_sync_url": reverse("compose_history_sync"),
|
||||
"compose_toggle_command_url": reverse("compose_toggle_command"),
|
||||
"compose_answer_suggestion_send_url": reverse("compose_answer_suggestion_send"),
|
||||
"compose_ws_url": ws_url,
|
||||
"tasks_hub_url": reverse("tasks_hub"),
|
||||
"tasks_group_url": reverse(
|
||||
"tasks_group",
|
||||
kwargs={
|
||||
"service": base["service"],
|
||||
"identifier": base["identifier"] or "_",
|
||||
},
|
||||
),
|
||||
"tasks_settings_scoped_url": (
|
||||
f"{reverse('tasks_settings')}?{urlencode({'service': base['service'], 'identifier': base['identifier'] or ''})}"
|
||||
),
|
||||
"ai_workspace_url": (
|
||||
f"{reverse('ai_workspace')}?person={base['person'].id}"
|
||||
if base["person"]
|
||||
|
||||
461
core/views/tasks.py
Normal file
461
core/views/tasks.py
Normal file
@@ -0,0 +1,461 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db.models import Count
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views import View
|
||||
|
||||
from core.clients.transport import send_message_raw
|
||||
from core.models import (
|
||||
AnswerSuggestionEvent,
|
||||
ChatTaskSource,
|
||||
DerivedTask,
|
||||
DerivedTaskEvent,
|
||||
ExternalSyncEvent,
|
||||
TaskCompletionPattern,
|
||||
TaskEpic,
|
||||
TaskProject,
|
||||
TaskProviderConfig,
|
||||
PersonIdentifier,
|
||||
PlatformChatLink,
|
||||
)
|
||||
from core.tasks.providers.mock import get_provider
|
||||
|
||||
|
||||
def _to_bool(raw, default=False) -> bool:
|
||||
if raw is None:
|
||||
return bool(default)
|
||||
value = str(raw).strip().lower()
|
||||
if value in {"1", "true", "yes", "on", "y"}:
|
||||
return True
|
||||
if value in {"0", "false", "no", "off", "n"}:
|
||||
return False
|
||||
return bool(default)
|
||||
|
||||
|
||||
def _parse_prefixes(value: str) -> list[str]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ["task:", "todo:"]
|
||||
rows = []
|
||||
for row in text.split(","):
|
||||
item = str(row or "").strip().lower()
|
||||
if item and item not in rows:
|
||||
rows.append(item)
|
||||
return rows or ["task:", "todo:"]
|
||||
|
||||
|
||||
def _flags_from_post(request, prefix: str = "") -> dict:
|
||||
key = lambda name: f"{prefix}{name}" if prefix else name
|
||||
return {
|
||||
"derive_enabled": _to_bool(request.POST.get(key("derive_enabled")), True),
|
||||
"match_mode": str(request.POST.get(key("match_mode")) or "strict").strip().lower() or "strict",
|
||||
"require_prefix": _to_bool(request.POST.get(key("require_prefix")), True),
|
||||
"allowed_prefixes": _parse_prefixes(str(request.POST.get(key("allowed_prefixes")) or "")),
|
||||
"completion_enabled": _to_bool(request.POST.get(key("completion_enabled")), True),
|
||||
"ai_title_enabled": _to_bool(request.POST.get(key("ai_title_enabled")), True),
|
||||
"announce_task_id": _to_bool(request.POST.get(key("announce_task_id")), True),
|
||||
"min_chars": max(1, int(str(request.POST.get(key("min_chars")) or "3").strip() or "3")),
|
||||
}
|
||||
|
||||
|
||||
def _flags_with_defaults(raw: dict | None) -> dict:
|
||||
row = dict(raw or {})
|
||||
return {
|
||||
"derive_enabled": _to_bool(row.get("derive_enabled"), True),
|
||||
"match_mode": str(row.get("match_mode") or "strict").strip().lower() or "strict",
|
||||
"require_prefix": _to_bool(row.get("require_prefix"), True),
|
||||
"allowed_prefixes": _parse_prefixes(",".join(list(row.get("allowed_prefixes") or []))),
|
||||
"completion_enabled": _to_bool(row.get("completion_enabled"), True),
|
||||
"ai_title_enabled": _to_bool(row.get("ai_title_enabled"), True),
|
||||
"announce_task_id": _to_bool(row.get("announce_task_id"), True),
|
||||
"min_chars": max(1, int(row.get("min_chars") or 3)),
|
||||
}
|
||||
|
||||
|
||||
def _settings_redirect(request):
|
||||
service = str(request.POST.get("prefill_service") or request.GET.get("service") or "").strip()
|
||||
identifier = str(request.POST.get("prefill_identifier") or request.GET.get("identifier") or "").strip()
|
||||
if service and identifier:
|
||||
return redirect(f"{request.path}?{urlencode({'service': service, 'identifier': identifier})}")
|
||||
return redirect("tasks_settings")
|
||||
|
||||
|
||||
def _service_label(service: str) -> str:
|
||||
key = str(service or "").strip().lower()
|
||||
labels = {
|
||||
"signal": "Signal",
|
||||
"whatsapp": "WhatsApp",
|
||||
"instagram": "Instagram",
|
||||
"xmpp": "XMPP",
|
||||
"web": "Web",
|
||||
}
|
||||
return labels.get(key, key.title() if key else "Unknown")
|
||||
|
||||
|
||||
def _resolve_channel_display(user, service: str, identifier: str) -> dict:
|
||||
service_key = str(service or "").strip().lower()
|
||||
raw_identifier = str(identifier or "").strip()
|
||||
bare_identifier = raw_identifier.split("@", 1)[0].strip()
|
||||
variants = [raw_identifier]
|
||||
if bare_identifier and bare_identifier not in variants:
|
||||
variants.append(bare_identifier)
|
||||
if service_key == "whatsapp":
|
||||
group_identifier = f"{bare_identifier}@g.us" if bare_identifier else ""
|
||||
if group_identifier and group_identifier not in variants:
|
||||
variants.append(group_identifier)
|
||||
|
||||
group_link = None
|
||||
if bare_identifier:
|
||||
group_link = (
|
||||
PlatformChatLink.objects.filter(
|
||||
user=user,
|
||||
service=service_key,
|
||||
chat_identifier=bare_identifier,
|
||||
is_group=True,
|
||||
)
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)
|
||||
|
||||
person_identifier = (
|
||||
PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
service=service_key,
|
||||
identifier__in=variants,
|
||||
)
|
||||
.select_related("person")
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)
|
||||
|
||||
display_name = ""
|
||||
if group_link and str(group_link.chat_name or "").strip():
|
||||
display_name = str(group_link.chat_name or "").strip()
|
||||
elif person_identifier and person_identifier.person_id:
|
||||
display_name = str(person_identifier.person.name or "").strip()
|
||||
if not display_name:
|
||||
display_name = raw_identifier or bare_identifier or "Unknown chat"
|
||||
|
||||
display_identifier = raw_identifier
|
||||
if group_link:
|
||||
display_identifier = (
|
||||
str(group_link.chat_jid or "").strip()
|
||||
or (f"{bare_identifier}@g.us" if bare_identifier else raw_identifier)
|
||||
)
|
||||
elif service_key == "whatsapp" and bare_identifier and not raw_identifier.endswith("@g.us"):
|
||||
display_identifier = f"{bare_identifier}@g.us"
|
||||
|
||||
return {
|
||||
"service_key": service_key,
|
||||
"service_label": _service_label(service_key),
|
||||
"display_name": display_name,
|
||||
"display_identifier": display_identifier or raw_identifier,
|
||||
"variants": [row for row in variants if row],
|
||||
}
|
||||
|
||||
|
||||
class TasksHub(LoginRequiredMixin, View):
|
||||
template_name = "pages/tasks-hub.html"
|
||||
|
||||
def get(self, request):
|
||||
projects = TaskProject.objects.filter(user=request.user).annotate(
|
||||
task_count=Count("derived_tasks")
|
||||
).order_by("name")
|
||||
tasks = (
|
||||
DerivedTask.objects.filter(user=request.user)
|
||||
.select_related("project", "epic")
|
||||
.order_by("-created_at")[:200]
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"projects": projects,
|
||||
"tasks": tasks,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TaskProjectDetail(LoginRequiredMixin, View):
|
||||
template_name = "pages/tasks-project.html"
|
||||
|
||||
def get(self, request, project_id):
|
||||
project = get_object_or_404(TaskProject, id=project_id, user=request.user)
|
||||
tasks = (
|
||||
DerivedTask.objects.filter(user=request.user, project=project)
|
||||
.select_related("epic")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
epics = TaskEpic.objects.filter(project=project).order_by("name")
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"project": project,
|
||||
"tasks": tasks,
|
||||
"epics": epics,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TaskEpicDetail(LoginRequiredMixin, View):
|
||||
template_name = "pages/tasks-epic.html"
|
||||
|
||||
def get(self, request, epic_id):
|
||||
epic = get_object_or_404(TaskEpic, id=epic_id, project__user=request.user)
|
||||
tasks = (
|
||||
DerivedTask.objects.filter(user=request.user, epic=epic)
|
||||
.select_related("project")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
return render(request, self.template_name, {"epic": epic, "tasks": tasks})
|
||||
|
||||
|
||||
class TaskGroupDetail(LoginRequiredMixin, View):
|
||||
template_name = "pages/tasks-group.html"
|
||||
|
||||
def get(self, request, service, identifier):
|
||||
channel = _resolve_channel_display(request.user, service, identifier)
|
||||
variants = list(channel.get("variants") or [str(identifier or "").strip()])
|
||||
mappings = ChatTaskSource.objects.filter(
|
||||
user=request.user,
|
||||
service=channel["service_key"],
|
||||
channel_identifier__in=variants,
|
||||
).select_related("project", "epic")
|
||||
tasks = (
|
||||
DerivedTask.objects.filter(
|
||||
user=request.user,
|
||||
source_service=channel["service_key"],
|
||||
source_channel__in=variants,
|
||||
)
|
||||
.select_related("project", "epic")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"service": channel["service_key"],
|
||||
"service_label": channel["service_label"],
|
||||
"identifier": channel["display_identifier"],
|
||||
"channel_display_name": channel["display_name"],
|
||||
"mappings": mappings,
|
||||
"tasks": tasks,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TaskDetail(LoginRequiredMixin, View):
|
||||
template_name = "pages/tasks-detail.html"
|
||||
|
||||
def get(self, request, task_id):
|
||||
task = get_object_or_404(
|
||||
DerivedTask.objects.select_related("project", "epic"),
|
||||
id=task_id,
|
||||
user=request.user,
|
||||
)
|
||||
events = task.events.select_related("source_message").order_by("-created_at")
|
||||
sync_events = task.external_sync_events.order_by("-created_at")
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"task": task,
|
||||
"events": events,
|
||||
"sync_events": sync_events,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TaskSettings(LoginRequiredMixin, View):
|
||||
template_name = "pages/tasks-settings.html"
|
||||
|
||||
def _context(self, request):
|
||||
prefill_service = str(request.GET.get("service") or "").strip().lower()
|
||||
prefill_identifier = str(request.GET.get("identifier") or "").strip()
|
||||
projects = list(TaskProject.objects.filter(user=request.user).order_by("name"))
|
||||
for row in projects:
|
||||
row.settings_effective = _flags_with_defaults(row.settings)
|
||||
row.allowed_prefixes_csv = ",".join(row.settings_effective["allowed_prefixes"])
|
||||
sources = list(
|
||||
ChatTaskSource.objects.filter(user=request.user)
|
||||
.select_related("project", "epic")
|
||||
.order_by("service", "channel_identifier")
|
||||
)
|
||||
for row in sources:
|
||||
row.settings_effective = _flags_with_defaults(row.settings)
|
||||
row.allowed_prefixes_csv = ",".join(row.settings_effective["allowed_prefixes"])
|
||||
return {
|
||||
"projects": projects,
|
||||
"epics": TaskEpic.objects.filter(project__user=request.user).select_related("project").order_by("project__name", "name"),
|
||||
"sources": sources,
|
||||
"patterns": TaskCompletionPattern.objects.filter(user=request.user).order_by("position", "created_at"),
|
||||
"provider_configs": TaskProviderConfig.objects.filter(user=request.user).order_by("provider"),
|
||||
"sync_events": ExternalSyncEvent.objects.filter(user=request.user).order_by("-updated_at")[:100],
|
||||
"prefill_service": prefill_service,
|
||||
"prefill_identifier": prefill_identifier,
|
||||
}
|
||||
|
||||
def get(self, request):
|
||||
return render(request, self.template_name, self._context(request))
|
||||
|
||||
def post(self, request):
|
||||
action = str(request.POST.get("action") or "").strip()
|
||||
|
||||
if action == "project_create":
|
||||
TaskProject.objects.create(
|
||||
user=request.user,
|
||||
name=str(request.POST.get("name") or "Project").strip() or "Project",
|
||||
external_key=str(request.POST.get("external_key") or "").strip(),
|
||||
active=bool(request.POST.get("active") or "1"),
|
||||
settings=_flags_from_post(request),
|
||||
)
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "epic_create":
|
||||
project = get_object_or_404(TaskProject, id=request.POST.get("project_id"), user=request.user)
|
||||
TaskEpic.objects.create(
|
||||
project=project,
|
||||
name=str(request.POST.get("name") or "Epic").strip() or "Epic",
|
||||
external_key=str(request.POST.get("external_key") or "").strip(),
|
||||
active=bool(request.POST.get("active") or "1"),
|
||||
)
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "source_create":
|
||||
project = get_object_or_404(TaskProject, id=request.POST.get("project_id"), user=request.user)
|
||||
epic = None
|
||||
epic_id = str(request.POST.get("epic_id") or "").strip()
|
||||
if epic_id:
|
||||
epic = get_object_or_404(TaskEpic, id=epic_id, project__user=request.user)
|
||||
ChatTaskSource.objects.create(
|
||||
user=request.user,
|
||||
service=str(request.POST.get("service") or "web").strip(),
|
||||
channel_identifier=str(request.POST.get("channel_identifier") or "").strip(),
|
||||
project=project,
|
||||
epic=epic,
|
||||
enabled=bool(request.POST.get("enabled") or "1"),
|
||||
settings=_flags_from_post(request, prefix="source_"),
|
||||
)
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "quick_setup":
|
||||
service = str(request.POST.get("service") or "web").strip().lower() or "web"
|
||||
channel_identifier = str(request.POST.get("channel_identifier") or "").strip()
|
||||
project_name = str(request.POST.get("project_name") or "").strip() or "General"
|
||||
epic_name = str(request.POST.get("epic_name") or "").strip()
|
||||
project, _ = TaskProject.objects.get_or_create(
|
||||
user=request.user,
|
||||
name=project_name,
|
||||
defaults={"settings": _flags_from_post(request)},
|
||||
)
|
||||
if not project.settings:
|
||||
project.settings = _flags_from_post(request)
|
||||
project.save(update_fields=["settings", "updated_at"])
|
||||
epic = None
|
||||
if epic_name:
|
||||
epic, _ = TaskEpic.objects.get_or_create(project=project, name=epic_name)
|
||||
if channel_identifier:
|
||||
source, created = ChatTaskSource.objects.get_or_create(
|
||||
user=request.user,
|
||||
service=service,
|
||||
channel_identifier=channel_identifier,
|
||||
project=project,
|
||||
defaults={
|
||||
"epic": epic,
|
||||
"enabled": True,
|
||||
"settings": _flags_from_post(request, prefix="source_"),
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
source.project = project
|
||||
source.epic = epic
|
||||
source.enabled = True
|
||||
source.settings = _flags_from_post(request, prefix="source_")
|
||||
source.save(update_fields=["project", "epic", "enabled", "settings", "updated_at"])
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "project_flags_update":
|
||||
project = get_object_or_404(TaskProject, id=request.POST.get("project_id"), user=request.user)
|
||||
project.settings = _flags_from_post(request)
|
||||
project.save(update_fields=["settings", "updated_at"])
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "source_flags_update":
|
||||
source = get_object_or_404(ChatTaskSource, id=request.POST.get("source_id"), user=request.user)
|
||||
source.settings = _flags_from_post(request, prefix="source_")
|
||||
source.save(update_fields=["settings", "updated_at"])
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "pattern_create":
|
||||
phrase = str(request.POST.get("phrase") or "").strip()
|
||||
if phrase:
|
||||
TaskCompletionPattern.objects.get_or_create(
|
||||
user=request.user,
|
||||
phrase=phrase,
|
||||
defaults={"enabled": True, "position": TaskCompletionPattern.objects.filter(user=request.user).count()},
|
||||
)
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "provider_update":
|
||||
provider = str(request.POST.get("provider") or "mock").strip() or "mock"
|
||||
row, _ = TaskProviderConfig.objects.get_or_create(
|
||||
user=request.user,
|
||||
provider=provider,
|
||||
defaults={"enabled": False, "settings": {}},
|
||||
)
|
||||
row.enabled = bool(request.POST.get("enabled"))
|
||||
row.save(update_fields=["enabled", "updated_at"])
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "sync_retry":
|
||||
event = get_object_or_404(ExternalSyncEvent, id=request.POST.get("event_id"), user=request.user)
|
||||
provider = get_provider(event.provider)
|
||||
payload = dict(event.payload or {})
|
||||
result = provider.append_update({}, payload)
|
||||
event.status = "ok" if result.ok else "failed"
|
||||
event.error = str(result.error or "")
|
||||
event.payload = dict(payload, retried=True)
|
||||
event.save(update_fields=["status", "error", "payload", "updated_at"])
|
||||
return _settings_redirect(request)
|
||||
|
||||
return _settings_redirect(request)
|
||||
|
||||
|
||||
class AnswerSuggestionSend(LoginRequiredMixin, View):
|
||||
def post(self, request):
|
||||
event = get_object_or_404(
|
||||
AnswerSuggestionEvent.objects.select_related("candidate_answer", "message"),
|
||||
id=request.POST.get("suggestion_id"),
|
||||
user=request.user,
|
||||
status="suggested",
|
||||
)
|
||||
decision = str(request.POST.get("decision") or "accept").strip().lower()
|
||||
if decision == "dismiss":
|
||||
event.status = "dismissed"
|
||||
event.save(update_fields=["status", "updated_at"])
|
||||
return JsonResponse({"ok": True, "status": "dismissed"})
|
||||
|
||||
text = str(getattr(event.candidate_answer, "answer_text", "") or "").strip()
|
||||
msg = event.message
|
||||
if not text:
|
||||
return JsonResponse({"ok": False, "error": "empty_candidate_answer"}, status=400)
|
||||
ok = async_to_sync(send_message_raw)(
|
||||
msg.source_service or "web",
|
||||
msg.source_chat_id or "",
|
||||
text=text,
|
||||
attachments=[],
|
||||
metadata={"origin": "repeat_answer_suggestion"},
|
||||
)
|
||||
event.status = "accepted" if ok else "suggested"
|
||||
event.save(update_fields=["status", "updated_at"])
|
||||
if not ok:
|
||||
messages.error(request, "Failed to send suggestion message.")
|
||||
return JsonResponse({"ok": False, "error": "send_failed"}, status=502)
|
||||
return JsonResponse({"ok": True, "status": "accepted"})
|
||||
Reference in New Issue
Block a user