Compare commits

...

2 Commits

Author SHA1 Message Date
chaim 405ceb7684 fix(alerts): broaden task detection to include Contact-linked tasks
Tasks with parentType='Contact' were invisible to AlertCalculator,
CaseContextBuilder, and OfficeContextBuilder — all queries only checked
parentType='Case'. This caused false "no preparation task" alerts in
the daily standup even when preparation tasks existed.

Broadens getSummary, findOverdueTasks, findUpcomingTasks, getWorkloadByUser
to query parentType=['Case','Contact']. Adds caseHasPreparation() helper
that checks Case tasks, Contact tasks, and NhActivity-linked tasks.
Adds getCaseContactIds() to CaseContextBuilder and OfficeContextBuilder.

Refs Task Master #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 06:07:04 +00:00
chaim 2569003f33 fix: broaden task detection to include Contact-linked tasks
Tasks created with parentType='Contact' (e.g. preparation calls linked
to a case's contact) were invisible to AlertCalculator, CaseContextBuilder,
and OfficeContextBuilder — all of which only queried parentType='Case'.

This caused false "no preparation task" alerts in Shira's daily standup
even when preparation tasks existed, because they were linked to the
case's Contact instead of the Case entity directly.

Changes:
- AlertCalculator: getSummary, findOverdueTasks, findUpcomingTasks,
  getWorkloadByUser now query parentType=['Case','Contact']
- findUpcomingHearingsWithoutPrep: new caseHasPreparation() helper
  checks Case tasks, Contact tasks, and NhActivity-linked tasks
- CaseContextBuilder.getOpenTasks: OR query across Case + Contact IDs
- OfficeContextBuilder.buildCaseDrillDown: same OR query pattern
- All three classes gain a getCaseContactIds() helper

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 06:02:27 +00:00
7 changed files with 248 additions and 16 deletions
+46
View File
@@ -0,0 +1,46 @@
{
"models": {
"main": {
"provider": "anthropic",
"modelId": "claude-sonnet-4-20250514",
"maxTokens": 64000,
"temperature": 0.2,
"id": "claude-sonnet-4-20250514"
},
"research": {
"provider": "anthropic",
"modelId": "sonar",
"maxTokens": 8700,
"temperature": 0.1,
"id": "claude-sonnet-4-20250514"
},
"fallback": {
"provider": "anthropic",
"modelId": "claude-3-7-sonnet-20250219",
"maxTokens": 120000,
"temperature": 0.2
}
},
"global": {
"logLevel": "info",
"debug": false,
"defaultNumTasks": 10,
"defaultSubtasks": 5,
"defaultPriority": "medium",
"projectName": "Task Master",
"ollamaBaseURL": "http://localhost:11434/api",
"bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com",
"responseLanguage": "English",
"enableCodebaseAnalysis": true,
"enableProxy": false,
"anonymousTelemetry": true,
"userId": "1234567890"
},
"claudeCode": {},
"codexCli": {},
"grokCli": {
"timeout": 120000,
"workingDirectory": null,
"defaultModel": "grok-4-latest"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"currentTag": "master",
"lastSwitched": "2026-04-14T06:05:06.044Z",
"branchTagMapping": {},
"migrationNoticeShown": false
}
+22
View File
@@ -0,0 +1,22 @@
{
"master": {
"tasks": [
{
"id": 1,
"title": "fix: broaden task detection to include Contact-linked tasks",
"description": "AlertCalculator, CaseContextBuilder, and OfficeContextBuilder only queried tasks with parentType='Case', missing tasks linked to case contacts (parentType='Contact'). This caused false 'no preparation task' alerts in Shira's daily standup.",
"status": "in-progress",
"priority": "high",
"dependencies": [],
"details": "Root cause confirmed via production API: preparation tasks had parentType='Contact' because Shira created them linked to the contact entity. Fix broadens all task queries to include both Case and Contact parent types, plus NhActivity-linked tasks.",
"testStrategy": "Deploy to staging, trigger standup for a case with Contact-linked tasks, verify no false alert.",
"subtasks": []
}
],
"metadata": {
"created": "2026-04-14T06:05:06.042Z",
"updated": "2026-04-14T09:15:00.000Z",
"description": "Tasks for master context"
}
}
}
@@ -46,7 +46,7 @@ class AlertCalculator
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
$totalOverdueTasks = $this->entityManager->getRDBRepository('Task')
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])->count();
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => ['Case', 'Contact']])->count();
$upcomingHearings = $this->entityManager->getRDBRepository('Case')
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $weekEnd, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
@@ -106,7 +106,7 @@ class AlertCalculator
$tasks = $this->entityManager->getRDBRepository('Task')
->select(['id', 'name', 'dateEnd', 'status', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => ['Case', 'Contact']])
->order('dateEnd', 'ASC')->limit(0, 50)->find();
foreach ($tasks as $task) {
@@ -132,7 +132,7 @@ class AlertCalculator
$recentTaskThreshold = date('Y-m-d H:i:s', strtotime('-7 days'));
$cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt'])
->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt', 'contactId'])
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $threshold, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])
->order('cNextHearing', 'ASC')->find();
@@ -140,13 +140,7 @@ class AlertCalculator
$lastActivity = $case->get('cLastActivityAt');
if ($lastActivity && $lastActivity >= $recentActivityThreshold) continue;
$openTaskCount = $this->entityManager->getRDBRepository('Task')
->where(['parentId' => $case->get('id'), 'parentType' => 'Case', 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false])->count();
if ($openTaskCount > 0) continue;
$recentCompleted = $this->entityManager->getRDBRepository('Task')
->where(['parentId' => $case->get('id'), 'parentType' => 'Case', 'status' => 'Completed', 'modifiedAt>=' => $recentTaskThreshold, 'deleted' => false])->count();
if ($recentCompleted > 0) continue;
if ($this->caseHasPreparation($case, $recentTaskThreshold)) continue;
$daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
$severity = ($daysUntil <= 1) ? 'critical' : 'warning';
@@ -162,6 +156,104 @@ class AlertCalculator
return $alerts;
}
/**
* Check if a case has any evidence of hearing preparation:
* 1. Tasks linked directly to the case (parentType='Case')
* 2. Tasks linked to the case's contacts (parentType='Contact')
* 3. Tasks linked through NhActivity records for this case
*/
private function caseHasPreparation(\Espo\ORM\Entity $case, string $recentTaskThreshold): bool
{
$caseId = $case->get('id');
// Collect all parentId+parentType pairs that relate to this case
$parentConditions = [
['parentType' => 'Case', 'parentId' => $caseId],
];
// Also check tasks linked to the case's contacts
$contactIds = $this->getCaseContactIds($case);
if (!empty($contactIds)) {
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
}
// Check 1: Any open tasks linked to the case or its contacts
$openTaskCount = $this->entityManager->getRDBRepository('Task')
->where([
'OR' => $parentConditions,
'status!=' => ['Completed', 'Canceled', 'Deferred'],
'deleted' => false,
])->count();
if ($openTaskCount > 0) return true;
// Check 2: Any recently completed tasks linked to the case or its contacts
$recentCompleted = $this->entityManager->getRDBRepository('Task')
->where([
'OR' => $parentConditions,
'status' => 'Completed',
'modifiedAt>=' => $recentTaskThreshold,
'deleted' => false,
])->count();
if ($recentCompleted > 0) return true;
// Check 3: Tasks linked through NhActivity records for this case
$nhActivityTaskCount = $this->entityManager->getRDBRepository('NhActivity')
->where([
'caseId' => $caseId,
'taskId!=' => null,
'deleted' => false,
])->count();
if ($nhActivityTaskCount > 0) {
$nhActivities = $this->entityManager->getRDBRepository('NhActivity')
->select(['taskId'])
->where([
'caseId' => $caseId,
'taskId!=' => null,
'deleted' => false,
])->find();
$taskIds = [];
foreach ($nhActivities as $nha) {
$taskIds[] = $nha->get('taskId');
}
$activeNhTasks = $this->entityManager->getRDBRepository('Task')
->where([
'id' => $taskIds,
'status!=' => ['Canceled'],
'deleted' => false,
])->count();
if ($activeNhTasks > 0) return true;
}
return false;
}
private function getCaseContactIds(\Espo\ORM\Entity $case): array
{
$contactIds = [];
$primaryContactId = $case->get('contactId');
if ($primaryContactId) {
$contactIds[] = $primaryContactId;
}
$contacts = $this->entityManager->getRDBRepository('Case')
->getRelation($case, 'contacts')
->select(['id'])
->find();
foreach ($contacts as $contact) {
$id = $contact->get('id');
if (!in_array($id, $contactIds)) {
$contactIds[] = $id;
}
}
return $contactIds;
}
private function findUnassignedNewCases(): array
{
$alerts = [];
@@ -191,7 +283,7 @@ class AlertCalculator
$tasks = $this->entityManager->getRDBRepository('Task')
->select(['id', 'name', 'dateEnd', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => 'Case'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => ['Case', 'Contact']])
->order('dateEnd', 'ASC')->limit(0, 30)->find();
foreach ($tasks as $task) {
@@ -237,7 +329,7 @@ class AlertCalculator
foreach (array_keys($userCounts) as $uid) {
$userCounts[$uid]['openTasks'] = $this->entityManager->getRDBRepository('Task')
->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => 'Case'])->count();
->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => ['Case', 'Contact']])->count();
}
return array_values($userCounts);
@@ -82,9 +82,18 @@ class CaseContextBuilder
private function getOpenTasks(string $caseId): array
{
$parentConditions = [
['parentType' => 'Case', 'parentId' => $caseId],
];
$contactIds = $this->getCaseContactIds($caseId);
if (!empty($contactIds)) {
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
}
$tasks = [];
$collection = $this->entityManager->getRDBRepository('Task')
->where(['parentType' => 'Case', 'parentId' => $caseId, 'status!=' => ['Completed', 'Canceled']])
->where(['OR' => $parentConditions, 'status!=' => ['Completed', 'Canceled']])
->order('dateEnd', 'ASC')->limit(0, 10)->find();
foreach ($collection as $t) {
@@ -97,6 +106,32 @@ class CaseContextBuilder
return $tasks;
}
private function getCaseContactIds(string $caseId): array
{
$case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) return [];
$contactIds = [];
$primaryContactId = $case->get('contactId');
if ($primaryContactId) {
$contactIds[] = $primaryContactId;
}
$contacts = $this->entityManager->getRDBRepository('Case')
->getRelation($case, 'contacts')
->select(['id'])
->find();
foreach ($contacts as $contact) {
$id = $contact->get('id');
if (!in_array($id, $contactIds)) {
$contactIds[] = $id;
}
}
return $contactIds;
}
private function getUpcomingMeetings(string $caseId): array
{
$meetings = [];
@@ -58,10 +58,18 @@ class OfficeContextBuilder
$contacts[] = ['name' => $c->get('name'), 'phone' => $c->get('phoneNumber'), 'email' => $c->get('emailAddress')];
}
$parentConditions = [
['parentType' => 'Case', 'parentId' => $caseId],
];
$contactIds = $this->getCaseContactIds($case);
if (!empty($contactIds)) {
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
}
$tasks = [];
foreach ($this->entityManager->getRDBRepository('Task')
->select(['id', 'name', 'status', 'dateEnd', 'assignedUserName'])
->where(['parentId' => $caseId, 'parentType' => 'Case', 'status!=' => ['Completed', 'Canceled'], 'deleted' => false])
->where(['OR' => $parentConditions, 'status!=' => ['Completed', 'Canceled'], 'deleted' => false])
->order('dateEnd', 'ASC')->limit(0, 20)->find() as $t) {
$tasks[] = ['name' => $t->get('name'), 'status' => $t->get('status'), 'dateEnd' => $t->get('dateEnd'), 'assignedUser' => $t->get('assignedUserName')];
}
@@ -76,4 +84,27 @@ class OfficeContextBuilder
return ['case' => $caseData, 'contacts' => $contacts, 'openTasks' => $tasks, 'recentNotes' => $notes];
}
private function getCaseContactIds(\Espo\ORM\Entity $case): array
{
$contactIds = [];
$primaryContactId = $case->get('contactId');
if ($primaryContactId) {
$contactIds[] = $primaryContactId;
}
$contacts = $this->entityManager->getRDBRepository('Case')
->getRelation($case, 'contacts')
->select(['id'])
->find();
foreach ($contacts as $contact) {
$id = $contact->get('id');
if (!in_array($id, $contactIds)) {
$contactIds[] = $id;
}
}
return $contactIds;
}
}
+2 -2
View File
@@ -3,11 +3,11 @@
"module": "SmartAssistant",
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
"author": "klear",
"version": "2.7.2",
"version": "2.7.3",
"acceptableVersions": [
">=8.0.0"
],
"releaseDate": "2026-04-09",
"releaseDate": "2026-04-14",
"php": [
">=8.1"
]