Improve tasks and backdate insights

This commit is contained in:
2026-03-03 17:21:06 +00:00
parent 9c14e51b43
commit 2898d9e832
18 changed files with 1617 additions and 264 deletions

View File

@@ -0,0 +1,424 @@
from __future__ import annotations
import statistics
from datetime import datetime, timezone
from django.core.management.base import BaseCommand
from django.utils import timezone as dj_timezone
from core.models import (
Message,
Person,
PersonIdentifier,
WorkspaceConversation,
WorkspaceMetricSnapshot,
)
from core.views.workspace import _conversation_for_person
def _score_from_lag(lag_ms, target_hours=4):
if lag_ms is None:
return 50.0
target_ms = max(1, int(target_hours)) * 60 * 60 * 1000
return max(0.0, min(100.0, 100.0 / (1.0 + (float(lag_ms) / target_ms))))
def _median_or_none(values):
if not values:
return None
return float(statistics.median(values))
def _calibrating_payload(last_ts=None):
return {
"source_event_ts": int(last_ts) if last_ts else None,
"stability_state": WorkspaceConversation.StabilityState.CALIBRATING,
"stability_score": None,
"stability_confidence": 0.0,
"stability_sample_messages": 0,
"stability_sample_days": 0,
"commitment_inbound_score": None,
"commitment_outbound_score": None,
"commitment_confidence": 0.0,
"inbound_messages": 0,
"outbound_messages": 0,
"reciprocity_score": None,
"continuity_score": None,
"response_score": None,
"volatility_score": None,
"inbound_response_score": None,
"outbound_response_score": None,
"balance_inbound_score": None,
"balance_outbound_score": None,
}
def _compute_payload(rows, identifier_values):
if not rows:
return _calibrating_payload(None)
inbound_count = 0
outbound_count = 0
daily_counts = {}
inbound_response_lags = []
outbound_response_lags = []
pending_in_ts = None
pending_out_ts = None
first_ts = int(rows[0]["ts"] or 0)
last_ts = int(rows[-1]["ts"] or 0)
latest_service = str(rows[-1].get("session__identifier__service") or "").strip().lower()
for row in rows:
ts = int(row.get("ts") or 0)
sender = str(row.get("sender_uuid") or "").strip()
author = str(row.get("custom_author") or "").strip().upper()
if author in {"USER", "BOT"}:
is_inbound = False
elif author == "OTHER":
is_inbound = True
else:
is_inbound = sender in identifier_values
direction = "in" if is_inbound else "out"
day_key = datetime.fromtimestamp(ts / 1000, tz=timezone.utc).date().isoformat()
daily_counts[day_key] = daily_counts.get(day_key, 0) + 1
if direction == "in":
inbound_count += 1
if pending_out_ts is not None and ts >= pending_out_ts:
inbound_response_lags.append(ts - pending_out_ts)
pending_out_ts = None
pending_in_ts = ts
else:
outbound_count += 1
if pending_in_ts is not None and ts >= pending_in_ts:
outbound_response_lags.append(ts - pending_in_ts)
pending_in_ts = None
pending_out_ts = ts
message_count = len(rows)
span_days = max(1, int(((last_ts - first_ts) / (24 * 60 * 60 * 1000)) + 1))
sample_days = len(daily_counts)
total_messages = max(1, inbound_count + outbound_count)
reciprocity_score = 100.0 * (
1.0 - abs(inbound_count - outbound_count) / total_messages
)
continuity_score = 100.0 * min(1.0, sample_days / max(1, span_days))
out_resp_score = _score_from_lag(_median_or_none(outbound_response_lags))
in_resp_score = _score_from_lag(_median_or_none(inbound_response_lags))
response_score = (out_resp_score + in_resp_score) / 2.0
daily_values = list(daily_counts.values())
if len(daily_values) > 1:
mean_daily = statistics.mean(daily_values)
stdev_daily = statistics.pstdev(daily_values)
cv = (stdev_daily / mean_daily) if mean_daily else 1.0
volatility_score = max(0.0, 100.0 * (1.0 - min(cv, 1.5) / 1.5))
else:
volatility_score = 60.0
stability_score = (
(0.35 * reciprocity_score)
+ (0.25 * continuity_score)
+ (0.20 * response_score)
+ (0.20 * volatility_score)
)
balance_out = 100.0 * min(1.0, outbound_count / max(1, inbound_count))
balance_in = 100.0 * min(1.0, inbound_count / max(1, outbound_count))
commitment_out = (0.60 * out_resp_score) + (0.40 * balance_out)
commitment_in = (0.60 * in_resp_score) + (0.40 * balance_in)
msg_conf = min(1.0, message_count / 200.0)
day_conf = min(1.0, sample_days / 30.0)
pair_conf = min(
1.0, (len(inbound_response_lags) + len(outbound_response_lags)) / 40.0
)
confidence = (0.50 * msg_conf) + (0.30 * day_conf) + (0.20 * pair_conf)
if message_count < 20 or sample_days < 3 or confidence < 0.25:
stability_state = WorkspaceConversation.StabilityState.CALIBRATING
stability_score_value = None
commitment_in_value = None
commitment_out_value = None
else:
stability_score_value = round(stability_score, 2)
commitment_in_value = round(commitment_in, 2)
commitment_out_value = round(commitment_out, 2)
if stability_score_value >= 70:
stability_state = WorkspaceConversation.StabilityState.STABLE
elif stability_score_value >= 50:
stability_state = WorkspaceConversation.StabilityState.WATCH
else:
stability_state = WorkspaceConversation.StabilityState.FRAGILE
feedback_state = "balanced"
if outbound_count > (inbound_count * 1.5):
feedback_state = "withdrawing"
elif inbound_count > (outbound_count * 1.5):
feedback_state = "overextending"
payload = {
"source_event_ts": last_ts,
"stability_state": stability_state,
"stability_score": float(stability_score_value)
if stability_score_value is not None
else None,
"stability_confidence": round(confidence, 3),
"stability_sample_messages": message_count,
"stability_sample_days": sample_days,
"commitment_inbound_score": float(commitment_in_value)
if commitment_in_value is not None
else None,
"commitment_outbound_score": float(commitment_out_value)
if commitment_out_value is not None
else None,
"commitment_confidence": round(confidence, 3),
"inbound_messages": inbound_count,
"outbound_messages": outbound_count,
"reciprocity_score": round(reciprocity_score, 3),
"continuity_score": round(continuity_score, 3),
"response_score": round(response_score, 3),
"volatility_score": round(volatility_score, 3),
"inbound_response_score": round(in_resp_score, 3),
"outbound_response_score": round(out_resp_score, 3),
"balance_inbound_score": round(balance_in, 3),
"balance_outbound_score": round(balance_out, 3),
}
return payload, latest_service, feedback_state
def _payload_signature(payload: dict) -> tuple:
return (
int(payload.get("source_event_ts") or 0),
str(payload.get("stability_state") or ""),
payload.get("stability_score"),
float(payload.get("stability_confidence") or 0.0),
int(payload.get("stability_sample_messages") or 0),
int(payload.get("stability_sample_days") or 0),
payload.get("commitment_inbound_score"),
payload.get("commitment_outbound_score"),
float(payload.get("commitment_confidence") or 0.0),
int(payload.get("inbound_messages") or 0),
int(payload.get("outbound_messages") or 0),
)
class Command(BaseCommand):
help = (
"Reconcile AI Workspace metric history by deterministically rebuilding "
"WorkspaceMetricSnapshot points from message history."
)
def add_arguments(self, parser):
parser.add_argument("--days", type=int, default=365)
parser.add_argument("--service", default="")
parser.add_argument("--user-id", default="")
parser.add_argument("--person-id", default="")
parser.add_argument("--step-messages", type=int, default=2)
parser.add_argument("--limit", type=int, default=200000)
parser.add_argument("--dry-run", action="store_true", default=False)
parser.add_argument("--no-reset", action="store_true", default=False)
def handle(self, *args, **options):
days = max(1, int(options.get("days") or 365))
service = str(options.get("service") or "").strip().lower()
user_id = str(options.get("user_id") or "").strip()
person_id = str(options.get("person_id") or "").strip()
step_messages = max(1, int(options.get("step_messages") or 2))
limit = max(1, int(options.get("limit") or 200000))
dry_run = bool(options.get("dry_run"))
reset = not bool(options.get("no_reset"))
today_start = dj_timezone.now().astimezone(timezone.utc).replace(
hour=0,
minute=0,
second=0,
microsecond=0,
)
cutoff_ts = int(
(today_start.timestamp() * 1000) - (days * 24 * 60 * 60 * 1000)
)
people_qs = Person.objects.all()
if user_id:
people_qs = people_qs.filter(user_id=user_id)
if person_id:
people_qs = people_qs.filter(id=person_id)
people = list(people_qs.order_by("user_id", "name", "id"))
conversations_scanned = 0
deleted = 0
snapshots_created = 0
checkpoints_total = 0
for person in people:
identifiers_qs = PersonIdentifier.objects.filter(user=person.user, person=person)
if service:
identifiers_qs = identifiers_qs.filter(service=service)
identifiers = list(identifiers_qs)
if not identifiers:
continue
identifier_values = {
str(row.identifier or "").strip() for row in identifiers if row.identifier
}
if not identifier_values:
continue
rows = list(
Message.objects.filter(
user=person.user,
session__identifier__in=identifiers,
ts__gte=cutoff_ts,
)
.order_by("ts", "id")
.values(
"id",
"ts",
"sender_uuid",
"custom_author",
"session__identifier__service",
)[:limit]
)
if not rows:
continue
conversation = _conversation_for_person(person.user, person)
conversations_scanned += 1
if reset and not dry_run:
deleted += WorkspaceMetricSnapshot.objects.filter(
conversation=conversation
).delete()[0]
existing_signatures = set()
if not reset:
existing_signatures = set(
_payload_signature(
{
"source_event_ts": row.source_event_ts,
"stability_state": row.stability_state,
"stability_score": row.stability_score,
"stability_confidence": row.stability_confidence,
"stability_sample_messages": row.stability_sample_messages,
"stability_sample_days": row.stability_sample_days,
"commitment_inbound_score": row.commitment_inbound_score,
"commitment_outbound_score": row.commitment_outbound_score,
"commitment_confidence": row.commitment_confidence,
"inbound_messages": row.inbound_messages,
"outbound_messages": row.outbound_messages,
}
)
for row in WorkspaceMetricSnapshot.objects.filter(
conversation=conversation
).only(
"source_event_ts",
"stability_state",
"stability_score",
"stability_confidence",
"stability_sample_messages",
"stability_sample_days",
"commitment_inbound_score",
"commitment_outbound_score",
"commitment_confidence",
"inbound_messages",
"outbound_messages",
)
)
checkpoints = list(range(step_messages, len(rows) + 1, step_messages))
if not checkpoints or checkpoints[-1] != len(rows):
checkpoints.append(len(rows))
checkpoints_total += len(checkpoints)
latest_payload = None
latest_service = ""
latest_feedback_state = "balanced"
for stop in checkpoints:
computed = _compute_payload(rows[:stop], identifier_values)
payload = computed[0]
latest_payload = payload
latest_service = computed[1]
latest_feedback_state = computed[2]
signature = _payload_signature(payload)
if not reset and signature in existing_signatures:
continue
snapshots_created += 1
if dry_run:
continue
WorkspaceMetricSnapshot.objects.create(conversation=conversation, **payload)
existing_signatures.add(signature)
if not latest_payload:
continue
feedback = dict(conversation.participant_feedback or {})
feedback[str(person.id)] = {
"state": latest_feedback_state,
"inbound_messages": int(latest_payload.get("inbound_messages") or 0),
"outbound_messages": int(latest_payload.get("outbound_messages") or 0),
"sample_messages": int(
latest_payload.get("stability_sample_messages") or 0
),
"sample_days": int(latest_payload.get("stability_sample_days") or 0),
"updated_at": dj_timezone.now().isoformat(),
}
if not dry_run:
conversation.platform_type = latest_service or conversation.platform_type
conversation.last_event_ts = latest_payload.get("source_event_ts")
conversation.stability_state = str(
latest_payload.get("stability_state")
or WorkspaceConversation.StabilityState.CALIBRATING
)
conversation.stability_score = latest_payload.get("stability_score")
conversation.stability_confidence = float(
latest_payload.get("stability_confidence") or 0.0
)
conversation.stability_sample_messages = int(
latest_payload.get("stability_sample_messages") or 0
)
conversation.stability_sample_days = int(
latest_payload.get("stability_sample_days") or 0
)
conversation.commitment_inbound_score = latest_payload.get(
"commitment_inbound_score"
)
conversation.commitment_outbound_score = latest_payload.get(
"commitment_outbound_score"
)
conversation.commitment_confidence = float(
latest_payload.get("commitment_confidence") or 0.0
)
now_ts = dj_timezone.now()
conversation.stability_last_computed_at = now_ts
conversation.commitment_last_computed_at = now_ts
conversation.participant_feedback = feedback
conversation.save(
update_fields=[
"platform_type",
"last_event_ts",
"stability_state",
"stability_score",
"stability_confidence",
"stability_sample_messages",
"stability_sample_days",
"stability_last_computed_at",
"commitment_inbound_score",
"commitment_outbound_score",
"commitment_confidence",
"commitment_last_computed_at",
"participant_feedback",
]
)
self.stdout.write(
self.style.SUCCESS(
"reconcile_workspace_metric_history complete "
f"conversations_scanned={conversations_scanned} "
f"checkpoints={checkpoints_total} "
f"created={snapshots_created} "
f"deleted={deleted} "
f"reset={reset} dry_run={dry_run} "
f"days={days} step_messages={step_messages} limit={limit}"
)
)

View File

@@ -13,7 +13,7 @@
<div class="column is-12"> <div class="column is-12">
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Information: {{ person.name }}</h1> <h1 class="title is-4" style="margin-bottom: 0.35rem;">Information: {{ person.name }}</h1>
<p class="is-size-7 has-text-grey">Commitment directionality and underlying metric factors.</p> <p class="is-size-7 has-text-grey">Commitment directionality and underlying metric factors from deterministic message-history snapshots.</p>
{% include "partials/ai-insight-nav.html" with active_tab="information" %} {% include "partials/ai-insight-nav.html" with active_tab="information" %}
</div> </div>

View File

@@ -14,7 +14,7 @@
<div class="column is-12"> <div class="column is-12">
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Insight Graphs: {{ person.name }}</h1> <h1 class="title is-4" style="margin-bottom: 0.35rem;">Insight Graphs: {{ person.name }}</h1>
<p class="is-size-7 has-text-grey"> <p class="is-size-7 has-text-grey">
Historical metrics for workspace {{ workspace_conversation.id }} Historical metrics for workspace {{ workspace_conversation.id }}. Points come from deterministic message-history snapshots (not only mitigation runs).
</p> </p>
{% include "partials/ai-insight-nav.html" with active_tab="graphs" %} {% include "partials/ai-insight-nav.html" with active_tab="graphs" %}
</div> </div>

View File

@@ -15,7 +15,8 @@
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Scoring Help: {{ person.name }}</h1> <h1 class="title is-4" style="margin-bottom: 0.35rem;">Scoring Help: {{ person.name }}</h1>
<p class="is-size-7 has-text-grey"> <p class="is-size-7 has-text-grey">
Combined explanation for each metric collection group and what it can Combined explanation for each metric collection group and what it can
imply in relationship dynamics. imply in relationship dynamics. Scoring is deterministic from message
history and can be backfilled via metric history reconciliation.
</p> </p>
{% include "partials/ai-insight-nav.html" with active_tab="help" %} {% include "partials/ai-insight-nav.html" with active_tab="help" %}
</div> </div>

View File

@@ -23,102 +23,45 @@
<button class="button is-link is-small" type="submit">Save</button> <button class="button is-link is-small" type="submit">Save</button>
</form> </form>
<form method="get" class="box">
<h2 class="title is-6">Timeline Filters</h2>
<div class="columns is-multiline">
<div class="column is-3">
<label class="label is-size-7">Person</label>
<div class="select is-small is-fullwidth">
<select name="person">
<option value="">All</option>
{% for person in people %}
<option value="{{ person.id }}" {% if filters.person == person.id|stringformat:"s" %}selected{% endif %}>{{ person.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-2">
<label class="label is-size-7">Service</label>
<div class="select is-small is-fullwidth">
<select name="service">
<option value="">All</option>
{% for item in service_choices %}
<option value="{{ item }}" {% if filters.service == item %}selected{% endif %}>{{ item }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-2">
<label class="label is-size-7">State</label>
<div class="select is-small is-fullwidth">
<select name="state">
<option value="">All</option>
{% for item in state_choices %}
<option value="{{ item }}" {% if filters.state == item %}selected{% endif %}>{{ item }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-2">
<label class="label is-size-7">Source</label>
<div class="select is-small is-fullwidth">
<select name="source_kind">
<option value="">All</option>
{% for item in source_kind_choices %}
<option value="{{ item }}" {% if filters.source_kind == item %}selected{% endif %}>{{ item }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-2">
<label class="label is-size-7">Start (ISO)</label>
<input class="input is-small" type="datetime-local" name="start" value="{{ filters.start }}">
</div>
<div class="column is-2">
<label class="label is-size-7">End (ISO)</label>
<input class="input is-small" type="datetime-local" name="end" value="{{ filters.end }}">
</div>
</div>
<button class="button is-light is-small" type="submit">Apply</button>
</form>
<div class="box"> <div class="box">
<h2 class="title is-6">Availability Events</h2> <h2 class="title is-6">Availability Event Statistics Per Contact</h2>
<table class="table is-fullwidth is-striped is-size-7"> <table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>ts</th><th>person</th><th>service</th><th>source</th><th>state</th><th>confidence</th></tr></thead> <thead>
<tr>
<th>Contact</th>
<th>Service</th>
<th>Total</th>
<th>Available</th>
<th>Fading</th>
<th>Unavailable</th>
<th>Unknown</th>
<th>Native</th>
<th>Read</th>
<th>Typing</th>
<th>Msg Activity</th>
<th>Timeout</th>
<th>Last Event TS</th>
</tr>
</thead>
<tbody> <tbody>
{% for row in events %} {% for row in contact_stats %}
<tr> <tr>
<td>{{ row.ts }}</td> <td>{{ row.person__name }}</td>
<td>{{ row.person.name }}</td>
<td>{{ row.service }}</td> <td>{{ row.service }}</td>
<td>{{ row.source_kind }}</td> <td>{{ row.total_events }}</td>
<td>{{ row.availability_state }}</td> <td>{{ row.available_events }}</td>
<td>{{ row.confidence|floatformat:2 }}</td> <td>{{ row.fading_events }}</td>
<td>{{ row.unavailable_events }}</td>
<td>{{ row.unknown_events }}</td>
<td>{{ row.native_presence_events }}</td>
<td>{{ row.read_receipt_events }}</td>
<td>{{ row.typing_events }}</td>
<td>{{ row.message_activity_events }}</td>
<td>{{ row.inferred_timeout_events }}</td>
<td>{{ row.last_event_ts }}</td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="6">No events in range.</td></tr> <tr><td colspan="13">No availability events found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="box">
<h2 class="title is-6">Availability Spans</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>person</th><th>service</th><th>state</th><th>start</th><th>end</th><th>confidence</th></tr></thead>
<tbody>
{% for row in spans %}
<tr>
<td>{{ row.person.name }}</td>
<td>{{ row.service }}</td>
<td>{{ row.state }}</td>
<td>{{ row.start_ts }}</td>
<td>{{ row.end_ts }}</td>
<td>{{ row.confidence_start|floatformat:2 }} -> {{ row.confidence_end|floatformat:2 }}</td>
</tr>
{% empty %}
<tr><td colspan="6">No spans in range.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -448,6 +448,13 @@
font-size: 0.78rem; font-size: 0.78rem;
line-height: 1.35; line-height: 1.35;
} }
.command-variant-warning strong {
color: #3f2a09;
}
.command-variant-warning code {
color: #5b3a0c;
background: rgba(255, 255, 255, 0.55);
}
.command-destination-list { .command-destination-list {
list-style: none; list-style: none;
margin: 0; margin: 0;

View File

@@ -3,6 +3,47 @@
<section class="section"><div class="container"> <section class="section"><div class="container">
<h1 class="title is-4">Group Tasks: {{ channel_display_name }}</h1> <h1 class="title is-4">Group Tasks: {{ channel_display_name }}</h1>
<p class="subtitle is-6">{{ service_label }} · {{ identifier }}</p> <p class="subtitle is-6">{{ service_label }} · {{ identifier }}</p>
<article class="box">
<h2 class="title is-6">Create Or Map Project</h2>
<form method="post" style="margin-bottom: 0.7rem;">
{% csrf_token %}
<input type="hidden" name="action" value="group_project_create">
<div class="columns is-multiline">
<div class="column is-5">
<label class="label is-size-7">Project Name</label>
<input class="input is-small" name="project_name" placeholder="Project name">
</div>
<div class="column is-5">
<label class="label is-size-7">Initial Epic (optional)</label>
<input class="input is-small" name="epic_name" placeholder="Epic name">
</div>
<div class="column is-2" style="display:flex; align-items:flex-end;">
<button class="button is-small is-link is-light" type="submit">Create + Map</button>
</div>
</div>
</form>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="group_map_existing_project">
<div class="columns is-multiline">
<div class="column is-9">
<label class="label is-size-7">Existing Project</label>
<div class="select is-small is-fullwidth">
<select name="project_id">
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% empty %}
<option value="">No projects available</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-3" style="display:flex; align-items:flex-end;">
<button class="button is-small is-light" type="submit">Map Existing</button>
</div>
</div>
</form>
</article>
{% if not tasks %} {% if not tasks %}
<article class="box"> <article class="box">
<h2 class="title is-6">No Tasks Yet</h2> <h2 class="title is-6">No Tasks Yet</h2>
@@ -19,23 +60,46 @@
{% endif %} {% endif %}
<article class="box"> <article class="box">
<h2 class="title is-6">Mappings</h2> <h2 class="title is-6">Mappings</h2>
<ul> <table class="table is-fullwidth is-striped is-size-7">
{% for row in mappings %} <thead><tr><th>Project</th><th>Epic</th><th>Channel</th><th>Enabled</th><th></th></tr></thead>
<li>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</li> <tbody>
{% empty %} {% for row in mappings %}
<li>No mappings for this group.</li> <tr>
{% endfor %} <td>{{ row.project.name }}</td>
</ul> <td>{% if row.epic %}{{ row.epic.name }}{% else %}-{% endif %}</td>
<td>
<div><code>{{ row.service }} · {{ row.channel_identifier }}</code></div>
{% if channel_display_name %}
<p class="is-size-7 has-text-dark" style="margin-top:0.2rem;">{{ channel_display_name }}</p>
{% endif %}
</td>
<td>{{ row.enabled }}</td>
<td><a class="button is-small is-light" href="{% url 'tasks_project' project_id=row.project_id %}">Open Project</a></td>
</tr>
{% empty %}
<tr><td colspan="5">No mappings for this group.</td></tr>
{% endfor %}
</tbody>
</table>
</article> </article>
<article class="box"> <article class="box">
<h2 class="title is-6">Derived Tasks</h2> <h2 class="title is-6">Derived Tasks</h2>
<ul> <table class="table is-fullwidth is-striped is-size-7">
{% for row in tasks %} <thead><tr><th>Ref</th><th>Title</th><th>Project</th><th>Status</th><th></th></tr></thead>
<li><a href="{% url 'tasks_task' task_id=row.id %}">#{{ row.reference_code }} {{ row.title }}</a></li> <tbody>
{% empty %} {% for row in tasks %}
<li>No tasks yet.</li> <tr>
{% endfor %} <td>#{{ row.reference_code }}</td>
</ul> <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 tasks yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article> </article>
</div></section> </div></section>
{% endblock %} {% endblock %}

View File

@@ -4,20 +4,130 @@
<div class="container"> <div class="container">
<h1 class="title is-4">Tasks</h1> <h1 class="title is-4">Tasks</h1>
<p class="subtitle is-6">Immutable tasks derived from chat activity.</p> <p class="subtitle is-6">Immutable tasks derived from chat activity.</p>
<div class="buttons"> <div class="buttons" style="margin-bottom: 0.75rem;">
<a class="button is-small is-link is-light" href="{% url 'tasks_settings' %}">Task Settings</a> <a class="button is-small is-link is-light" href="{% url 'tasks_settings' %}">Task Settings</a>
</div> </div>
<div class="columns"> <div class="columns is-variable is-5">
<div class="column is-4"> <div class="column is-4">
<article class="box"> <article class="box">
<h2 class="title is-6">Projects</h2> <div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.6rem;">
<ul> <h2 class="title is-6" style="margin: 0;">Projects</h2>
<span class="tag task-stat-tag">{{ projects|length }}</span>
</div>
<p class="help" style="margin-bottom: 0.45rem;">Create the project first, then map linked identifiers below in one click.</p>
<form method="post" style="margin-bottom: 0.75rem;">
{% csrf_token %}
<input type="hidden" name="action" value="project_create">
<input type="hidden" name="person" value="{{ scope.person_id }}">
<input type="hidden" name="service" value="{{ scope.service }}">
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input is-small" name="name" placeholder="New project name">
</div>
<div class="control">
<button class="button is-small is-link is-light" type="submit">Add</button>
</div>
</div>
</form>
{% if scope.person %}
<article class="message is-light" style="margin-bottom: 0.75rem;">
<div class="message-body is-size-7">
Setup scope: <strong>{{ scope.person.name }}</strong>
{% if scope.service and scope.identifier %}
· {{ scope.service }} · {{ scope.identifier }}
{% endif %}
</div>
</article>
<div style="margin-bottom: 0.75rem;">
<label class="label is-size-7">Map Linked Identifiers To Project</label>
<form method="get">
<input type="hidden" name="person" value="{{ scope.person_id }}">
<input type="hidden" name="service" value="{{ scope.service }}">
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
<div class="field has-addons">
<div class="control is-expanded">
<div class="select is-small is-fullwidth">
<select name="project">
<option value="">Select project</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if selected_project and selected_project.id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<button class="button is-small is-light" type="submit">Select</button>
</div>
</div>
</form>
</div>
<table class="table is-fullwidth is-striped is-size-7" style="margin-bottom:0.9rem;">
<thead><tr><th>Identifier</th><th>Service</th><th></th></tr></thead>
<tbody>
{% for row in person_identifiers %}
<tr>
<td><code>{{ row.identifier }}</code></td>
<td>{{ row.service }}</td>
<td class="has-text-right">
{% if selected_project %}
{% if tuple(selected_project.id, row.service, row.identifier) in mapping_pairs %}
<span class="tag task-stat-tag">Linked</span>
{% else %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="project_map_identifier">
<input type="hidden" name="project_id" value="{{ selected_project.id }}">
<input type="hidden" name="person_identifier_id" value="{{ row.id }}">
<input type="hidden" name="person" value="{{ scope.person_id }}">
<input type="hidden" name="service" value="{{ scope.service }}">
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
<button class="button is-small is-light" type="submit">Link</button>
</form>
{% endif %}
{% else %}
<span class="has-text-grey">Select project</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="3">No linked identifiers for this person yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="help" style="margin-bottom: 0.75rem;">
Open this page from Compose to map a persons linked identifiers in one click.
</p>
{% endif %}
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Project</th><th>Stats</th><th></th></tr></thead>
<tbody>
{% for project in projects %} {% 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> <tr>
<td>
<a href="{% url 'tasks_project' project_id=project.id %}">{{ project.name }}</a>
</td>
<td>
<span class="tag task-stat-tag">{{ project.task_count }} task{{ project.task_count|pluralize }}</span>
<span class="tag task-stat-tag">{{ project.epic_count }} epic{{ project.epic_count|pluralize }}</span>
</td>
<td class="has-text-right">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="project_delete">
<input type="hidden" name="project_id" value="{{ project.id }}">
<button class="button is-small is-danger is-light" type="submit">Delete</button>
</form>
</td>
</tr>
{% empty %} {% empty %}
<li>No projects yet.</li> <tr><td colspan="3">No projects yet.</td></tr>
{% endfor %} {% endfor %}
</ul> </tbody>
</table>
</article> </article>
</div> </div>
<div class="column"> <div class="column">
@@ -44,4 +154,15 @@
</div> </div>
</div> </div>
</section> </section>
<style>
.task-stat-tag {
background: #f5f5f5;
border: 1px solid #dbdbdb;
color: #1f1f1f !important;
font-size: 0.75rem;
line-height: 1.5;
padding: 0.25em 0.75em;
font-weight: 500;
}
</style>
{% endblock %} {% endblock %}

View File

@@ -1,27 +1,75 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section class="section"><div class="container"> <section class="section">
<h1 class="title is-4">Project: {{ project.name }}</h1> <div class="container">
<div class="buttons"><a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a></div> <h1 class="title is-4">Project: {{ project.name }}</h1>
<article class="box"> <div class="buttons" style="margin-bottom: 0.75rem;">
<h2 class="title is-6">Epics</h2> <a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a>
<ul> <form method="post">
{% for epic in epics %} {% csrf_token %}
<li><a href="{% url 'tasks_epic' epic_id=epic.id %}">{{ epic.name }}</a></li> <input type="hidden" name="action" value="project_delete">
{% empty %} <button class="button is-small is-danger is-light" type="submit">Delete Project</button>
<li>No epics.</li> </form>
{% endfor %} </div>
</ul>
</article> <article class="box">
<article class="box"> <div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.6rem;">
<h2 class="title is-6">Tasks</h2> <h2 class="title is-6" style="margin: 0;">Epics</h2>
<ul> <span class="tag is-light">{{ epics|length }}</span>
{% for row in tasks %} </div>
<li><a href="{% url 'tasks_task' task_id=row.id %}">#{{ row.reference_code }} {{ row.title }}</a></li> <form method="post" style="margin-bottom: 0.75rem;">
{% empty %} {% csrf_token %}
<li>No tasks.</li> <input type="hidden" name="action" value="epic_create">
{% endfor %} <div class="field has-addons">
</ul> <div class="control is-expanded">
</article> <input class="input is-small" name="name" placeholder="New epic name">
</div></section> </div>
<div class="control">
<button class="button is-small is-link is-light" type="submit">Add Epic</button>
</div>
</div>
</form>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Epic</th><th>Tasks</th><th></th></tr></thead>
<tbody>
{% for epic in epics %}
<tr>
<td><a href="{% url 'tasks_epic' epic_id=epic.id %}">{{ epic.name }}</a></td>
<td><span class="tag is-light">{{ epic.task_count }}</span></td>
<td class="has-text-right">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="epic_delete">
<input type="hidden" name="epic_id" value="{{ epic.id }}">
<button class="button is-small is-danger is-light" type="submit">Delete</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="3">No epics.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Tasks</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Ref</th><th>Title</th><th>Epic</th><th></th></tr></thead>
<tbody>
{% for row in tasks %}
<tr>
<td>#{{ row.reference_code }}</td>
<td>{{ row.title }}</td>
<td>{% if row.epic %}{{ row.epic.name }}{% else %}-{% endif %}</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="4">No tasks.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
</section>
{% endblock %} {% endblock %}

View File

@@ -335,6 +335,7 @@
<input type="hidden" name="provider" value="codex_cli"> <input type="hidden" name="provider" value="codex_cli">
<label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if codex_provider_config and codex_provider_config.enabled %}checked{% endif %}> Enable Codex CLI provider</label> <label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if codex_provider_config and codex_provider_config.enabled %}checked{% endif %}> Enable Codex CLI provider</label>
<p class="help">Codex task-sync runs in a dedicated worker (<code>python manage.py codex_worker</code>).</p> <p class="help">Codex task-sync runs in a dedicated worker (<code>python manage.py codex_worker</code>).</p>
<p class="help">This provider syncs task updates to Codex; it does not mirror whole chat threads in this phase.</p>
<div class="field" style="margin-top:0.5rem;"> <div class="field" style="margin-top:0.5rem;">
<label class="label is-size-7">Command</label> <label class="label is-size-7">Command</label>
<input class="input is-small" name="command" value="{{ codex_provider_settings.command }}" placeholder="codex"> <input class="input is-small" name="command" value="{{ codex_provider_settings.command }}" placeholder="codex">
@@ -361,40 +362,69 @@
<div class="column is-12"> <div class="column is-12">
<section class="tasks-panel"> <section class="tasks-panel">
<h3 class="title is-7">External Chat Links</h3> <h3 class="title is-7">External Chat Links</h3>
<p class="help">Map a contact to an external Codex chat/session ID for task-sync metadata.</p> <p class="help">Map a GIA contact identifier to one Codex conversation/session so task-sync updates are routed to the correct Codex thread.</p>
{% if external_link_scoped %}
<article class="message is-info is-light tasks-link-scope-note">
<div class="message-body">
Scoped to <strong>{{ external_link_scope_label }}</strong>. Only matching identifiers are available below.
</div>
</article>
{% endif %}
<form method="post" class="block"> <form method="post" class="block">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="external_chat_link_upsert"> <input type="hidden" name="action" value="external_chat_link_upsert">
<div class="columns tasks-settings-inline-columns"> <input type="hidden" name="prefill_service" value="{{ prefill_service }}">
<div class="column is-2"> <input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
<label class="label is-size-7">Provider</label> <div class="columns is-multiline is-variable is-2 tasks-external-link-columns">
<div class="select is-small is-fullwidth"> <div class="column is-12-mobile is-4-tablet is-2-desktop">
<select name="provider"> <div class="field">
<option value="codex_cli" selected>codex_cli</option> <label class="label is-size-7">Provider</label>
</select> <div class="control">
<div class="select is-small is-fullwidth">
<select name="provider">
<option value="codex_cli" selected>codex_cli</option>
</select>
</div>
</div>
</div> </div>
</div> </div>
<div class="column is-5"> <div class="column is-12-mobile is-8-tablet is-5-desktop">
<label class="label is-size-7">Contact Identifier</label> <div class="field">
<div class="select is-small is-fullwidth"> <label class="label is-size-7">Contact Identifier</label>
<select name="person_identifier_id"> <div class="control">
<option value="">Unlinked</option> <div class="select is-small is-fullwidth">
{% for row in person_identifiers %} <select name="person_identifier_id">
<option value="{{ row.id }}">{{ row.person.name }} · {{ row.service }} · {{ row.identifier }}</option> <option value="">Unlinked</option>
{% endfor %} {% for row in external_link_person_identifiers %}
</select> <option value="{{ row.id }}">{{ row.person.name }} · {{ row.service }} · {{ row.identifier }}</option>
{% endfor %}
</select>
</div>
</div>
<p class="help">Choose which contact/group in GIA this Codex chat mapping belongs to.</p>
</div> </div>
</div> </div>
<div class="column is-3"> <div class="column is-12-mobile is-8-tablet is-3-desktop">
<label class="label is-size-7">External Chat ID</label> <div class="field">
<input class="input is-small" name="external_chat_id" placeholder="codex-chat-..."> <label class="label is-size-7">External Chat ID</label>
<div class="control">
<input class="input is-small" name="external_chat_id" placeholder="codex-chat-...">
</div>
<p class="help">Use the Codex conversation/session ID (the stable ID Codex worker should target for task updates).</p>
</div>
</div> </div>
<div class="column is-2"> <div class="column is-6-mobile is-4-tablet is-2-desktop">
<label class="label is-size-7">Enabled</label> <div class="field">
<label class="checkbox"><input type="checkbox" name="enabled" value="1" checked> Active</label> <label class="label is-size-7">Enabled</label>
<label class="checkbox"><input type="checkbox" name="enabled" value="1" checked> Active</label>
</div>
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-small is-link is-light" type="submit">Save Link</button>
</div> </div>
</div> </div>
<button class="button is-small is-link is-light" type="submit">Save Link</button>
</form> </form>
<table class="table is-fullwidth is-striped is-size-7"> <table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Provider</th><th>Person</th><th>Identifier</th><th>External Chat</th><th>Enabled</th><th></th></tr></thead> <thead><tr><th>Provider</th><th>Person</th><th>Identifier</th><th>External Chat</th><th>Enabled</th><th></th></tr></thead>
@@ -452,6 +482,12 @@
.tasks-settings-page .tasks-settings-list { .tasks-settings-page .tasks-settings-list {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.tasks-settings-page .tasks-link-scope-note {
margin-bottom: 0.75rem;
}
.tasks-settings-page .tasks-external-link-columns .field {
margin-bottom: 0.5rem;
}
.tasks-settings-page .prefix-chip { .tasks-settings-page .prefix-chip {
margin-right: 0.25rem; margin-right: 0.25rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;

View File

@@ -3,7 +3,12 @@ from __future__ import annotations
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from core.models import ContactAvailabilitySettings, User from core.models import (
ContactAvailabilityEvent,
ContactAvailabilitySettings,
Person,
User,
)
class AvailabilitySettingsPageTests(TestCase): class AvailabilitySettingsPageTests(TestCase):
@@ -36,3 +41,36 @@ class AvailabilitySettingsPageTests(TestCase):
self.assertTrue(row.inference_enabled) self.assertTrue(row.inference_enabled)
self.assertEqual(120, row.retention_days) self.assertEqual(120, row.retention_days)
self.assertEqual(300, row.fade_threshold_seconds) self.assertEqual(300, row.fade_threshold_seconds)
def test_contact_event_stats_are_aggregated(self):
person = Person.objects.create(user=self.user, name="Alice")
ContactAvailabilityEvent.objects.create(
user=self.user,
person=person,
service="whatsapp",
source_kind="message_in",
availability_state="available",
confidence=0.9,
ts=1000,
payload={},
)
ContactAvailabilityEvent.objects.create(
user=self.user,
person=person,
service="whatsapp",
source_kind="inferred_timeout",
availability_state="fading",
confidence=0.5,
ts=2000,
payload={},
)
response = self.client.get(reverse("availability_settings"))
self.assertEqual(200, response.status_code)
stats = list(response.context["contact_stats"])
self.assertEqual(1, len(stats))
self.assertEqual("Alice", stats[0]["person__name"])
self.assertEqual(2, stats[0]["total_events"])
self.assertEqual(1, stats[0]["available_events"])
self.assertEqual(1, stats[0]["fading_events"])
self.assertEqual(1, stats[0]["message_activity_events"])
self.assertEqual(1, stats[0]["inferred_timeout_events"])

View File

@@ -0,0 +1,131 @@
from __future__ import annotations
from django.core.management import call_command
from django.test import TestCase
from core.models import (
ChatSession,
Message,
Person,
PersonIdentifier,
User,
WorkspaceMetricSnapshot,
)
from core.views.workspace import _conversation_for_person
class ReconcileWorkspaceMetricHistoryCommandTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
"reconcile-user",
"reconcile@example.com",
"x",
)
self.person = Person.objects.create(user=self.user, name="Reconcile Person")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
person=self.person,
service="whatsapp",
identifier="15551230000@s.whatsapp.net",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
base_ts = 1_700_000_000_000
for idx in range(10):
inbound = idx % 2 == 0
Message.objects.create(
user=self.user,
session=self.session,
ts=base_ts + (idx * 60_000),
text=f"m{idx}",
source_service="whatsapp",
source_chat_id="120363402761690215@g.us",
sender_uuid=f"actor-{idx}@s.whatsapp.net",
custom_author="OTHER" if inbound else "USER",
)
def test_reconcile_builds_checkpoints_and_no_reset_is_idempotent(self):
call_command(
"reconcile_workspace_metric_history",
"--person-id",
str(self.person.id),
"--service",
"whatsapp",
"--days",
"36500",
"--step-messages",
"2",
)
conversation = _conversation_for_person(self.user, self.person)
first_count = WorkspaceMetricSnapshot.objects.filter(
conversation=conversation
).count()
self.assertGreaterEqual(first_count, 5)
call_command(
"reconcile_workspace_metric_history",
"--person-id",
str(self.person.id),
"--service",
"whatsapp",
"--days",
"36500",
"--step-messages",
"2",
"--no-reset",
)
second_count = WorkspaceMetricSnapshot.objects.filter(
conversation=conversation
).count()
self.assertEqual(first_count, second_count)
latest = conversation.metric_snapshots.first()
self.assertEqual(5, int(latest.inbound_messages or 0))
self.assertEqual(5, int(latest.outbound_messages or 0))
def test_no_reset_does_not_duplicate_when_messages_share_timestamp(self):
Message.objects.all().delete()
base_ts = 1_700_100_000_000
for idx in range(8):
inbound = idx % 2 == 0
Message.objects.create(
user=self.user,
session=self.session,
ts=base_ts + ((idx // 2) * 60_000),
text=f"d{idx}",
source_service="whatsapp",
source_chat_id="120363402761690215@g.us",
sender_uuid=f"dup-{idx}@s.whatsapp.net",
custom_author="OTHER" if inbound else "USER",
)
call_command(
"reconcile_workspace_metric_history",
"--person-id",
str(self.person.id),
"--service",
"whatsapp",
"--days",
"36500",
"--step-messages",
"2",
)
conversation = _conversation_for_person(self.user, self.person)
first_count = WorkspaceMetricSnapshot.objects.filter(
conversation=conversation
).count()
call_command(
"reconcile_workspace_metric_history",
"--person-id",
str(self.person.id),
"--service",
"whatsapp",
"--days",
"36500",
"--step-messages",
"2",
"--no-reset",
)
second_count = WorkspaceMetricSnapshot.objects.filter(
conversation=conversation
).count()
self.assertEqual(first_count, second_count)

View File

@@ -0,0 +1,113 @@
from __future__ import annotations
from django.test import TestCase
from django.urls import reverse
from core.models import ChatTaskSource, TaskEpic, TaskProject, User
class TasksPagesManagementTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("tasks-pages-user", "tasks-pages@example.com", "x")
self.client.force_login(self.user)
def test_tasks_hub_requires_group_scope_for_project_create(self):
create_response = self.client.post(
reverse("tasks_hub"),
{
"action": "project_create",
"name": "Ops",
},
follow=True,
)
self.assertEqual(200, create_response.status_code)
self.assertFalse(TaskProject.objects.filter(user=self.user, name="Ops").exists())
def test_tasks_hub_can_create_scoped_project_and_delete(self):
create_response = self.client.post(
reverse("tasks_hub"),
{
"action": "project_create",
"name": "Ops",
"service": "whatsapp",
"channel_identifier": "120363402761690215@g.us",
},
follow=True,
)
self.assertEqual(200, create_response.status_code)
project = TaskProject.objects.get(user=self.user, name="Ops")
self.assertIsNotNone(project)
self.assertTrue(
ChatTaskSource.objects.filter(
user=self.user,
service="whatsapp",
channel_identifier="120363402761690215@g.us",
project=project,
).exists()
)
delete_response = self.client.post(
reverse("tasks_hub"),
{
"action": "project_delete",
"project_id": str(project.id),
},
follow=True,
)
self.assertEqual(200, delete_response.status_code)
self.assertFalse(TaskProject.objects.filter(user=self.user, name="Ops").exists())
def test_project_page_can_create_and_delete_epic(self):
project = TaskProject.objects.create(user=self.user, name="Roadmap")
create_response = self.client.post(
reverse("tasks_project", kwargs={"project_id": str(project.id)}),
{
"action": "epic_create",
"name": "Phase 1",
},
follow=True,
)
self.assertEqual(200, create_response.status_code)
epic = TaskEpic.objects.get(project=project, name="Phase 1")
self.assertIsNotNone(epic)
delete_response = self.client.post(
reverse("tasks_project", kwargs={"project_id": str(project.id)}),
{
"action": "epic_delete",
"epic_id": str(epic.id),
},
follow=True,
)
self.assertEqual(200, delete_response.status_code)
self.assertFalse(TaskEpic.objects.filter(project=project, name="Phase 1").exists())
def test_group_page_create_and_map_project(self):
response = self.client.post(
reverse(
"tasks_group",
kwargs={
"service": "whatsapp",
"identifier": "120363402761690215@g.us",
},
),
{
"action": "group_project_create",
"project_name": "Group Project",
"epic_name": "Sprint A",
},
follow=True,
)
self.assertEqual(200, response.status_code)
project = TaskProject.objects.get(user=self.user, name="Group Project")
epic = TaskEpic.objects.get(project=project, name="Sprint A")
self.assertTrue(
ChatTaskSource.objects.filter(
user=self.user,
service="whatsapp",
channel_identifier="120363402761690215@g.us",
project=project,
epic=epic,
enabled=True,
).exists()
)

View File

@@ -10,6 +10,7 @@ from core.models import (
ChatSession, ChatSession,
ChatTaskSource, ChatTaskSource,
DerivedTask, DerivedTask,
ExternalChatLink,
Message, Message,
Person, Person,
PersonIdentifier, PersonIdentifier,
@@ -203,3 +204,67 @@ class TaskSettingsViewActionsTests(TestCase):
self.assertFalse( self.assertFalse(
ChatTaskSource.objects.filter(id=self.source.id, user=self.user).exists() ChatTaskSource.objects.filter(id=self.source.id, user=self.user).exists()
) )
class TaskSettingsExternalChatLinkScopeTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("task-link-user", "task-link@example.com", "x")
self.client.force_login(self.user)
self.group_person = Person.objects.create(user=self.user, name="Scoped Group")
self.group_identifier = PersonIdentifier.objects.create(
user=self.user,
person=self.group_person,
service="whatsapp",
identifier="120363402761690215@g.us",
)
self.group_identifier_bare = PersonIdentifier.objects.create(
user=self.user,
person=self.group_person,
service="whatsapp",
identifier="120363402761690215",
)
self.other_person = Person.objects.create(user=self.user, name="Other Group")
self.other_identifier = PersonIdentifier.objects.create(
user=self.user,
person=self.other_person,
service="whatsapp",
identifier="120399999999999999@g.us",
)
def test_scoped_settings_limits_contact_identifier_options(self):
response = self.client.get(
reverse("tasks_settings"),
{
"service": "whatsapp",
"identifier": "120363402761690215@g.us",
},
)
self.assertEqual(200, response.status_code)
options = list(response.context["external_link_person_identifiers"])
self.assertTrue(any(row.id == self.group_identifier.id for row in options))
self.assertTrue(any(row.id == self.group_identifier_bare.id for row in options))
self.assertFalse(any(row.id == self.other_identifier.id for row in options))
self.assertTrue(bool(response.context["external_link_scoped"]))
def test_scoped_upsert_rejects_out_of_scope_identifier(self):
response = self.client.post(
reverse("tasks_settings"),
{
"action": "external_chat_link_upsert",
"provider": "codex_cli",
"person_identifier_id": str(self.other_identifier.id),
"external_chat_id": "codex-chat-abc",
"enabled": "1",
"prefill_service": "whatsapp",
"prefill_identifier": "120363402761690215@g.us",
},
follow=True,
)
self.assertEqual(200, response.status_code)
self.assertFalse(
ExternalChatLink.objects.filter(
user=self.user,
provider="codex_cli",
external_chat_id="codex-chat-abc",
).exists()
)

View File

@@ -1,16 +1,13 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Max, Q
from django.shortcuts import render from django.shortcuts import render
from django.views import View from django.views import View
from core.models import ( from core.models import (
ContactAvailabilityEvent, ContactAvailabilityEvent,
ContactAvailabilitySettings, ContactAvailabilitySettings,
ContactAvailabilitySpan,
Person,
) )
@@ -32,19 +29,6 @@ def _to_bool(value, default=False):
return bool(default) return bool(default)
def _iso_to_ms(value: str) -> int:
raw = str(value or "").strip()
if not raw:
return 0
try:
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp() * 1000)
except Exception:
return 0
class AvailabilitySettingsPage(LoginRequiredMixin, View): class AvailabilitySettingsPage(LoginRequiredMixin, View):
template_name = "pages/availability-settings.html" template_name = "pages/availability-settings.html"
@@ -81,67 +65,45 @@ class AvailabilitySettingsPage(LoginRequiredMixin, View):
def get(self, request): def get(self, request):
settings_row = self._settings(request) settings_row = self._settings(request)
person_id = str(request.GET.get("person") or "").strip() contact_stats = list(
service = str(request.GET.get("service") or "").strip().lower() ContactAvailabilityEvent.objects.filter(
state = str(request.GET.get("state") or "").strip().lower() user=request.user, person__isnull=False
source_kind = str(request.GET.get("source_kind") or "").strip().lower() )
start_ts = _iso_to_ms(request.GET.get("start")) .values("person_id", "person__name", "service")
end_ts = _iso_to_ms(request.GET.get("end")) .annotate(
if end_ts <= 0: total_events=Count("id"),
end_ts = int(datetime.now(tz=timezone.utc).timestamp() * 1000) available_events=Count(
if start_ts <= 0: "id", filter=Q(availability_state="available")
start_ts = max(0, end_ts - (14 * 24 * 60 * 60 * 1000)) ),
fading_events=Count("id", filter=Q(availability_state="fading")),
events_qs = ContactAvailabilityEvent.objects.filter(user=request.user) unavailable_events=Count(
spans_qs = ContactAvailabilitySpan.objects.filter(user=request.user) "id", filter=Q(availability_state="unavailable")
),
if person_id: unknown_events=Count("id", filter=Q(availability_state="unknown")),
events_qs = events_qs.filter(person_id=person_id) native_presence_events=Count(
spans_qs = spans_qs.filter(person_id=person_id) "id", filter=Q(source_kind="native_presence")
if service: ),
events_qs = events_qs.filter(service=service) read_receipt_events=Count("id", filter=Q(source_kind="read_receipt")),
spans_qs = spans_qs.filter(service=service) typing_events=Count(
if state: "id",
events_qs = events_qs.filter(availability_state=state) filter=Q(source_kind="typing_start")
spans_qs = spans_qs.filter(state=state) | Q(source_kind="typing_stop"),
if source_kind: ),
events_qs = events_qs.filter(source_kind=source_kind) message_activity_events=Count(
"id",
events_qs = events_qs.filter(ts__gte=start_ts, ts__lte=end_ts) filter=Q(source_kind="message_in")
spans_qs = spans_qs.filter(start_ts__lte=end_ts, end_ts__gte=start_ts) | Q(source_kind="message_out"),
),
events = list( inferred_timeout_events=Count(
events_qs.select_related("person", "person_identifier").order_by("-ts")[:500] "id", filter=Q(source_kind="inferred_timeout")
),
last_event_ts=Max("ts"),
)
.order_by("-total_events", "person__name", "service")
) )
spans = list(
spans_qs.select_related("person", "person_identifier").order_by("-end_ts")[:500]
)
people = list(Person.objects.filter(user=request.user).order_by("name"))
context = { context = {
"settings_row": settings_row, "settings_row": settings_row,
"people": people, "contact_stats": contact_stats,
"events": events,
"spans": spans,
"filters": {
"person": person_id,
"service": service,
"state": state,
"source_kind": source_kind,
"start": request.GET.get("start") or "",
"end": request.GET.get("end") or "",
},
"service_choices": ["signal", "whatsapp", "xmpp", "instagram", "web"],
"state_choices": ["available", "fading", "unavailable", "unknown"],
"source_kind_choices": [
"native_presence",
"read_receipt",
"typing_start",
"typing_stop",
"message_in",
"message_out",
"inferred_timeout",
],
} }
return render(request, self.template_name, context) return render(request, self.template_name, context)

View File

@@ -1228,7 +1228,7 @@ def _trend_meta(current, previous, higher_is_better=True):
} }
def _emotion_meta(metric_kind, value): def _emotion_meta(metric_kind, value, metric_key=None):
score = _to_float(value) score = _to_float(value)
if score is None: if score is None:
return { return {
@@ -1239,6 +1239,25 @@ def _emotion_meta(metric_kind, value):
if metric_kind == "confidence": if metric_kind == "confidence":
score = score * 100.0 score = score * 100.0
if metric_kind == "count": if metric_kind == "count":
key = str(metric_key or "").strip().lower()
if key == "sample_days":
if score >= 14:
return {
"icon": "fa-solid fa-calendar-check",
"class_name": "has-text-success",
"label": "Broad Coverage",
}
if score >= 5:
return {
"icon": "fa-solid fa-calendar-days",
"class_name": "has-text-warning",
"label": "Adequate Coverage",
}
return {
"icon": "fa-solid fa-calendar-xmark",
"class_name": "has-text-danger",
"label": "Narrow Coverage",
}
if score >= 80: if score >= 80:
return { return {
"icon": "fa-solid fa-chart-column", "icon": "fa-solid fa-chart-column",
@@ -1433,7 +1452,7 @@ def _quick_insights_rows(conversation):
point_count = conversation.metric_snapshots.exclude( point_count = conversation.metric_snapshots.exclude(
**{f"{field_name}__isnull": True} **{f"{field_name}__isnull": True}
).count() ).count()
emotion = _emotion_meta(spec["kind"], current) emotion = _emotion_meta(spec["kind"], current, spec["key"])
rows.append( rows.append(
{ {
"key": spec["key"], "key": spec["key"],
@@ -4035,6 +4054,7 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
"Arrow color indicates improving or risk direction for that metric.", "Arrow color indicates improving or risk direction for that metric.",
"State uses participant feedback (Withdrawing/Overextending/Balanced) when available.", "State uses participant feedback (Withdrawing/Overextending/Balanced) when available.",
"Values are computed from all linked platform messages for this person.", "Values are computed from all linked platform messages for this person.",
"Data labels are metric-specific (for example, day coverage is rated separately from message volume).",
"Face indicator maps value range to positive, mixed, or strained climate.", "Face indicator maps value range to positive, mixed, or strained climate.",
"Use this card for fast triage; open AI Workspace for full graphs and details.", "Use this card for fast triage; open AI Workspace for full graphs and details.",
], ],

View File

@@ -5,7 +5,9 @@ from urllib.parse import urlencode
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import IntegrityError
from django.db.models import Count from django.db.models import Count
from django.urls import reverse
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views import View from django.views import View
@@ -22,6 +24,7 @@ from core.models import (
TaskProject, TaskProject,
TaskProviderConfig, TaskProviderConfig,
PersonIdentifier, PersonIdentifier,
Person,
PlatformChatLink, PlatformChatLink,
Chat, Chat,
ExternalChatLink, ExternalChatLink,
@@ -178,6 +181,103 @@ def _provider_row_map(user):
} }
def _normalize_channel_identifier(service: str, identifier: str) -> str:
service_key = str(service or "").strip().lower()
value = str(identifier or "").strip()
if not value:
return ""
if service_key == "whatsapp":
bare = value.split("@", 1)[0].strip()
if bare:
if value.endswith("@g.us"):
return f"{bare}@g.us"
if value.endswith("@s.whatsapp.net"):
return f"{bare}@s.whatsapp.net"
return f"{bare}@g.us"
if service_key == "signal":
return value
if service_key == "xmpp":
return value
if service_key == "instagram":
return value
if service_key == "web":
return value
return value
def _upsert_group_source(*, user, service: str, channel_identifier: str, project, epic=None):
normalized_service = str(service or "").strip().lower()
normalized_identifier = _normalize_channel_identifier(service, channel_identifier)
if not normalized_service or not normalized_identifier:
return None
source, created = ChatTaskSource.objects.get_or_create(
user=user,
service=normalized_service,
channel_identifier=normalized_identifier,
project=project,
defaults={
"epic": epic,
"enabled": True,
"settings": _flags_with_defaults({}),
},
)
if not created:
next_fields = []
if source.epic_id != getattr(epic, "id", None):
source.epic = epic
next_fields.append("epic")
if not source.enabled:
source.enabled = True
next_fields.append("enabled")
if next_fields:
next_fields.append("updated_at")
source.save(update_fields=next_fields)
return source
def _person_identifier_scope_variants(service: str, identifier: str) -> list[str]:
service_key = str(service or "").strip().lower()
raw_identifier = str(identifier or "").strip()
if not service_key or not raw_identifier:
return []
variants: list[str] = [raw_identifier]
bare_identifier = raw_identifier.split("@", 1)[0].strip()
if bare_identifier and bare_identifier not in variants:
variants.append(bare_identifier)
if service_key == "whatsapp" and bare_identifier:
group_identifier = f"{bare_identifier}@g.us"
direct_identifier = f"{bare_identifier}@s.whatsapp.net"
if group_identifier not in variants:
variants.append(group_identifier)
if direct_identifier not in variants:
variants.append(direct_identifier)
if service_key == "signal":
digits = "".join(ch for ch in raw_identifier if ch.isdigit())
if digits and digits not in variants:
variants.append(digits)
if digits:
plus_variant = f"+{digits}"
if plus_variant not in variants:
variants.append(plus_variant)
return [row for row in variants if row]
def _scoped_person_identifier_rows(user, service: str, identifier: str):
service_key = str(service or "").strip().lower()
variants = _person_identifier_scope_variants(service_key, identifier)
if not service_key or not variants:
return PersonIdentifier.objects.none()
return (
PersonIdentifier.objects.filter(
user=user,
service=service_key,
identifier__in=variants,
)
.select_related("person")
.order_by("person__name", "service", "identifier")
)
def _resolve_channel_display(user, service: str, identifier: str) -> dict: def _resolve_channel_display(user, service: str, identifier: str) -> dict:
service_key = str(service or "").strip().lower() service_key = str(service or "").strip().lower()
raw_identifier = str(identifier or "").strip() raw_identifier = str(identifier or "").strip()
@@ -283,45 +383,227 @@ def _resolve_channel_display(user, service: str, identifier: str) -> dict:
class TasksHub(LoginRequiredMixin, View): class TasksHub(LoginRequiredMixin, View):
template_name = "pages/tasks-hub.html" template_name = "pages/tasks-hub.html"
def get(self, request): def _scope(self, request):
projects = TaskProject.objects.filter(user=request.user).annotate( person_id = str(request.GET.get("person") or request.POST.get("person") or "").strip()
task_count=Count("derived_tasks") person = None
).order_by("name") if person_id:
person = Person.objects.filter(user=request.user, id=person_id).first()
return {
"person": person,
"person_id": str(getattr(person, "id", "") or ""),
"service": str(request.GET.get("service") or request.POST.get("service") or "").strip().lower(),
"identifier": str(request.GET.get("identifier") or request.POST.get("identifier") or "").strip(),
"selected_project_id": str(
request.GET.get("project") or request.POST.get("project_id") or ""
).strip(),
}
def _context(self, request):
scope = self._scope(request)
projects = (
TaskProject.objects.filter(user=request.user)
.annotate(
task_count=Count("derived_tasks"),
epic_count=Count("epics", distinct=True),
)
.order_by("name")
)
tasks = ( tasks = (
DerivedTask.objects.filter(user=request.user) DerivedTask.objects.filter(user=request.user)
.select_related("project", "epic") .select_related("project", "epic")
.order_by("-created_at")[:200] .order_by("-created_at")[:200]
) )
return render( selected_project = None
request, if scope["selected_project_id"]:
self.template_name, selected_project = TaskProject.objects.filter(
{ user=request.user,
"projects": projects, id=scope["selected_project_id"],
"tasks": tasks, ).first()
}, person_identifiers = []
person_identifier_rows = []
if scope["person"] is not None:
person_identifiers = list(
PersonIdentifier.objects.filter(
user=request.user,
person=scope["person"],
).order_by("service", "identifier")
)
mapping_pairs = set(
ChatTaskSource.objects.filter(user=request.user)
.values_list("project_id", "service", "channel_identifier")
) )
for row in person_identifiers:
mapped = False
if selected_project is not None:
mapped = (
selected_project.id,
str(row.service or "").strip(),
str(row.identifier or "").strip(),
) in mapping_pairs
person_identifier_rows.append(
{
"id": row.id,
"identifier": str(row.identifier or "").strip(),
"service": str(row.service or "").strip(),
"mapped": mapped,
}
)
return {
"projects": projects,
"tasks": tasks,
"scope": scope,
"person_identifier_rows": person_identifier_rows,
"selected_project": selected_project,
}
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().lower()
if action == "project_create":
name = str(request.POST.get("name") or "").strip()
if not name:
messages.error(request, "Project name is required.")
return redirect("tasks_hub")
scope = self._scope(request)
external_key = str(request.POST.get("external_key") or "").strip()
try:
project, created = TaskProject.objects.get_or_create(
user=request.user,
name=name,
defaults={"external_key": external_key},
)
except IntegrityError:
messages.error(request, "Could not create project due to duplicate name.")
return redirect("tasks_hub")
if created:
messages.success(request, f"Created project '{project.name}'.")
else:
messages.info(request, f"Project '{project.name}' already exists.")
query = {}
if scope["person_id"]:
query["person"] = scope["person_id"]
if scope["service"]:
query["service"] = scope["service"]
if scope["identifier"]:
query["identifier"] = scope["identifier"]
query["project"] = str(project.id)
if query:
return redirect(f"{reverse('tasks_hub')}?{urlencode(query)}")
return redirect("tasks_hub")
if action == "project_map_identifier":
project = get_object_or_404(
TaskProject,
user=request.user,
id=request.POST.get("project_id"),
)
identifier_row = get_object_or_404(
PersonIdentifier,
user=request.user,
id=request.POST.get("person_identifier_id"),
)
_upsert_group_source(
user=request.user,
service=identifier_row.service,
channel_identifier=identifier_row.identifier,
project=project,
epic=None,
)
messages.success(
request,
f"Mapped {identifier_row.service} · {identifier_row.identifier} to '{project.name}'.",
)
scope = self._scope(request)
query = {
"project": str(project.id),
}
if scope["person_id"]:
query["person"] = scope["person_id"]
if scope["service"]:
query["service"] = scope["service"]
if scope["identifier"]:
query["identifier"] = scope["identifier"]
return redirect(f"{reverse('tasks_hub')}?{urlencode(query)}")
if action == "project_delete":
project = get_object_or_404(
TaskProject,
id=request.POST.get("project_id"),
user=request.user,
)
deleted_name = str(project.name or "").strip() or "Project"
project.delete()
messages.success(request, f"Deleted project '{deleted_name}'.")
return redirect("tasks_hub")
return redirect("tasks_hub")
class TaskProjectDetail(LoginRequiredMixin, View): class TaskProjectDetail(LoginRequiredMixin, View):
template_name = "pages/tasks-project.html" template_name = "pages/tasks-project.html"
def get(self, request, project_id): def _context(self, request, project):
project = get_object_or_404(TaskProject, id=project_id, user=request.user)
tasks = ( tasks = (
DerivedTask.objects.filter(user=request.user, project=project) DerivedTask.objects.filter(user=request.user, project=project)
.select_related("epic") .select_related("epic")
.order_by("-created_at") .order_by("-created_at")
) )
epics = TaskEpic.objects.filter(project=project).order_by("name") epics = (
return render( TaskEpic.objects.filter(project=project)
request, .annotate(task_count=Count("derived_tasks"))
self.template_name, .order_by("name")
{
"project": project,
"tasks": tasks,
"epics": epics,
},
) )
return {
"project": project,
"tasks": tasks,
"epics": epics,
}
def get(self, request, project_id):
project = get_object_or_404(TaskProject, id=project_id, user=request.user)
return render(request, self.template_name, self._context(request, project))
def post(self, request, project_id):
project = get_object_or_404(TaskProject, id=project_id, user=request.user)
action = str(request.POST.get("action") or "").strip().lower()
if action == "epic_create":
name = str(request.POST.get("name") or "").strip()
if not name:
messages.error(request, "Epic name is required.")
return redirect("tasks_project", project_id=str(project.id))
external_key = str(request.POST.get("external_key") or "").strip()
try:
epic, created = TaskEpic.objects.get_or_create(
project=project,
name=name,
defaults={"external_key": external_key},
)
except IntegrityError:
messages.error(request, "Could not create epic due to duplicate name.")
return redirect("tasks_project", project_id=str(project.id))
if created:
messages.success(request, f"Created epic '{epic.name}'.")
else:
messages.info(request, f"Epic '{epic.name}' already exists.")
return redirect("tasks_project", project_id=str(project.id))
if action == "epic_delete":
epic = get_object_or_404(TaskEpic, id=request.POST.get("epic_id"), project=project)
deleted_name = str(epic.name or "").strip() or "Epic"
epic.delete()
messages.success(request, f"Deleted epic '{deleted_name}'.")
return redirect("tasks_project", project_id=str(project.id))
if action == "project_delete":
deleted_name = str(project.name or "").strip() or "Project"
project.delete()
messages.success(request, f"Deleted project '{deleted_name}'.")
return redirect("tasks_hub")
return redirect("tasks_project", project_id=str(project.id))
class TaskEpicDetail(LoginRequiredMixin, View): class TaskEpicDetail(LoginRequiredMixin, View):
@@ -368,11 +650,67 @@ class TaskGroupDetail(LoginRequiredMixin, View):
"service_label": channel["service_label"], "service_label": channel["service_label"],
"identifier": channel["display_identifier"], "identifier": channel["display_identifier"],
"channel_display_name": channel["display_name"], "channel_display_name": channel["display_name"],
"projects": TaskProject.objects.filter(user=request.user).order_by("name"),
"mappings": mappings, "mappings": mappings,
"tasks": tasks, "tasks": tasks,
}, },
) )
def post(self, request, service, identifier):
channel = _resolve_channel_display(request.user, service, identifier)
action = str(request.POST.get("action") or "").strip().lower()
if action == "group_project_create":
project_name = str(request.POST.get("project_name") or "").strip()
if not project_name:
messages.error(request, "Project name is required.")
return redirect(
"tasks_group",
service=channel["service_key"],
identifier=channel["display_identifier"],
)
epic_name = str(request.POST.get("epic_name") or "").strip()
project, _ = TaskProject.objects.get_or_create(
user=request.user,
name=project_name,
)
epic = None
if epic_name:
epic, _ = TaskEpic.objects.get_or_create(project=project, name=epic_name)
_upsert_group_source(
user=request.user,
service=channel["service_key"],
channel_identifier=channel["display_identifier"],
project=project,
epic=epic,
)
messages.success(
request,
f"Project '{project.name}' mapped to this group.",
)
elif action == "group_map_existing_project":
project = get_object_or_404(
TaskProject,
user=request.user,
id=request.POST.get("project_id"),
)
epic = None
epic_id = str(request.POST.get("epic_id") or "").strip()
if epic_id:
epic = get_object_or_404(TaskEpic, project=project, id=epic_id)
_upsert_group_source(
user=request.user,
service=channel["service_key"],
channel_identifier=channel["display_identifier"],
project=project,
epic=epic,
)
messages.success(request, f"Mapped '{project.name}' to this group.")
return redirect(
"tasks_group",
service=channel["service_key"],
identifier=channel["display_identifier"],
)
class TaskDetail(LoginRequiredMixin, View): class TaskDetail(LoginRequiredMixin, View):
template_name = "pages/tasks-detail.html" template_name = "pages/tasks-detail.html"
@@ -425,6 +763,21 @@ class TaskSettings(LoginRequiredMixin, View):
"person", "person_identifier" "person", "person_identifier"
).order_by("-updated_at")[:200] ).order_by("-updated_at")[:200]
) )
person_identifiers = (
PersonIdentifier.objects.filter(user=request.user)
.select_related("person")
.order_by("person__name", "service", "identifier")[:600]
)
external_link_scoped = bool(prefill_service and prefill_identifier)
external_link_scope_label = ""
external_link_person_identifiers = person_identifiers
if external_link_scoped:
external_link_scope_label = f"{prefill_service} · {prefill_identifier}"
external_link_person_identifiers = _scoped_person_identifier_rows(
request.user,
prefill_service,
prefill_identifier,
)
return { return {
"projects": projects, "projects": projects,
@@ -441,7 +794,10 @@ class TaskSettings(LoginRequiredMixin, View):
"timeout_seconds": int(codex_settings.get("timeout_seconds") or 60), "timeout_seconds": int(codex_settings.get("timeout_seconds") or 60),
"chat_link_mode": str(codex_settings.get("chat_link_mode") or "task-sync"), "chat_link_mode": str(codex_settings.get("chat_link_mode") or "task-sync"),
}, },
"person_identifiers": PersonIdentifier.objects.filter(user=request.user).select_related("person").order_by("person__name", "service", "identifier")[:600], "person_identifiers": person_identifiers,
"external_link_person_identifiers": external_link_person_identifiers,
"external_link_scoped": external_link_scoped,
"external_link_scope_label": external_link_scope_label,
"external_chat_links": external_chat_links, "external_chat_links": external_chat_links,
"sync_events": ExternalSyncEvent.objects.filter(user=request.user).order_by("-updated_at")[:100], "sync_events": ExternalSyncEvent.objects.filter(user=request.user).order_by("-updated_at")[:100],
"prefill_service": prefill_service, "prefill_service": prefill_service,
@@ -588,6 +944,12 @@ class TaskSettings(LoginRequiredMixin, View):
provider = str(request.POST.get("provider") or "codex_cli").strip().lower() or "codex_cli" provider = str(request.POST.get("provider") or "codex_cli").strip().lower() or "codex_cli"
external_chat_id = str(request.POST.get("external_chat_id") or "").strip() external_chat_id = str(request.POST.get("external_chat_id") or "").strip()
person_identifier_id = str(request.POST.get("person_identifier_id") or "").strip() person_identifier_id = str(request.POST.get("person_identifier_id") or "").strip()
prefill_service = str(
request.POST.get("prefill_service") or request.GET.get("service") or ""
).strip().lower()
prefill_identifier = str(
request.POST.get("prefill_identifier") or request.GET.get("identifier") or ""
).strip()
if not external_chat_id: if not external_chat_id:
messages.error(request, "External chat ID is required.") messages.error(request, "External chat ID is required.")
return _settings_redirect(request) return _settings_redirect(request)
@@ -598,6 +960,18 @@ class TaskSettings(LoginRequiredMixin, View):
user=request.user, user=request.user,
id=person_identifier_id, id=person_identifier_id,
) )
if prefill_service and prefill_identifier:
allowed_ids = set(
_scoped_person_identifier_rows(
request.user, prefill_service, prefill_identifier
).values_list("id", flat=True)
)
if identifier.id not in allowed_ids:
messages.error(
request,
"Selected contact is outside the current scoped chat.",
)
return _settings_redirect(request)
row, _ = ExternalChatLink.objects.update_or_create( row, _ = ExternalChatLink.objects.update_or_create(
user=request.user, user=request.user,
provider=provider, provider=provider,

View File

@@ -2375,7 +2375,7 @@ def _refresh_conversation_stability(conversation, user, person):
session__identifier__in=identifiers, session__identifier__in=identifiers,
) )
.order_by("ts") .order_by("ts")
.values("ts", "sender_uuid", "session__identifier__service") .values("ts", "sender_uuid", "custom_author", "session__identifier__service")
) )
if not rows: if not rows:
conversation.stability_state = WorkspaceConversation.StabilityState.CALIBRATING conversation.stability_state = WorkspaceConversation.StabilityState.CALIBRATING
@@ -2446,7 +2446,13 @@ def _refresh_conversation_stability(conversation, user, person):
for row in rows: for row in rows:
ts = int(row["ts"] or 0) ts = int(row["ts"] or 0)
sender = str(row.get("sender_uuid") or "").strip() sender = str(row.get("sender_uuid") or "").strip()
is_inbound = sender in identifier_values author = str(row.get("custom_author") or "").strip().upper()
if author in {"USER", "BOT"}:
is_inbound = False
elif author == "OTHER":
is_inbound = True
else:
is_inbound = sender in identifier_values
direction = "in" if is_inbound else "out" direction = "in" if is_inbound else "out"
day_key = datetime.fromtimestamp(ts / 1000, tz=timezone.utc).date().isoformat() day_key = datetime.fromtimestamp(ts / 1000, tz=timezone.utc).date().isoformat()
daily_counts[day_key] = daily_counts.get(day_key, 0) + 1 daily_counts[day_key] = daily_counts.get(day_key, 0) + 1