Implement tasks

This commit is contained in:
2026-03-02 12:45:24 +00:00
parent 6986c1b5ab
commit e1de6d016d
29 changed files with 2970 additions and 172 deletions

0
core/assist/__init__.py Normal file
View File

13
core/assist/engine.py Normal file
View 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)

View 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,
)

View File

@@ -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(

View File

@@ -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,
)

View 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))

View File

@@ -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'),
),
]

View File

@@ -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 = (

View File

@@ -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
View File

341
core/tasks/engine.py Normal file
View 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

View File

View 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

View 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"])

View File

@@ -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>

View File

@@ -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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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>

View File

@@ -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(

View 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"))

View 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()
)

View File

@@ -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
View 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"})