feat: harden search ranking and deduplication

This commit is contained in:
zhaoyilun
2026-07-10 09:39:18 +08:00
parent 1d6dd2f504
commit 6ca6c08b62
13 changed files with 368 additions and 56 deletions

View File

@@ -56,7 +56,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme
## Current loop
Current loop: `Business Roadmap / R2 - Contact and group search quality hardening`.
Current loop: `Business Roadmap / R3 - Receive-file download mapping precheck`.
Plan file: `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`.
@@ -124,4 +124,12 @@ R1 receive-message source reconciliation precheck is complete:
- Created `docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md`.
- Decision: do not change default receive routing yet; empty/`auto` remains PacketReader/log-backed and `msglib_readonly` remains explicit.
- Next node: R2 contact/group search quality hardening.
- R1 next node was R2 contact/group search quality hardening.
R2 contact/group search quality hardening is complete:
- Search contacts now has deterministic exact-match-first ranking and case-insensitive de-duplication for log/JID candidates and optional MsgLib display enrichment.
- Search groups now has deterministic exact-match-first ranking and case-insensitive de-duplication for groupchat/conference candidates and optional MsgLib display enrichment.
- MCP output still preserves `source` and `raw_ref`; no send or download behavior was introduced.
- Next node: R3 receive-file download mapping precheck.

View File

@@ -96,8 +96,8 @@ The verification confirms:
- `isphere_receive_messages` returns the contract-facing `ok`, `conversation`, `messages`, `next_cursor`, and `audit` envelope, while preserving legacy message aliases such as `id`, `text`, and `timestamp`.
- A synthetic configured-source smoke check proves `ISPHERE_PACKET_LOG_FILE` can drive `isphere_receive_messages` end-to-end with one redacted fixture message.
- A synthetic configured-directory smoke check proves `ISPHERE_PACKET_LOG_DIR` can drive `isphere_receive_messages`, contact search, group search, and file-list extraction from a temporary directory fixture.
- `isphere_search_contacts` is callable and returns JID-derived contact candidates from the same configured synthetic message fixture.
- `isphere_search_groups` is callable and returns JID-derived group candidates from the configured synthetic groupchat fixture.
- `isphere_search_contacts` is callable and returns JID-derived contact candidates from the same configured synthetic message fixture; exact ID/display/account matches rank before prefix/substring matches and case-insensitive duplicates are collapsed.
- `isphere_search_groups` is callable and returns JID-derived group candidates from the configured synthetic groupchat fixture; exact ID/display-name matches rank before prefix/substring matches and case-insensitive duplicates are collapsed.
- `isphere_receive_files` is callable in list mode and returns one file metadata candidate from the configured synthetic file-transfer fixture.
- Verification uses synthetic/local helper checks and does not load raw N12-pre evidence.
- The deterministic verification path clears MsgLib env variables, so it proves the default no-MsgLib behavior remains stable.
@@ -246,8 +246,8 @@ These eight tools are currently allowed:
| `win_helper_scan_windows` | helper `scan_windows` | Read visible window metadata or likely iSphere candidates. |
| `win_helper_dump_uia` | helper `dump_uia` | Read a UI Automation tree for a specified window handle. |
| `isphere_receive_messages` | log-backed `EncryptedPacketLogSource` | Read normalized messages from the configured PacketReader log source with a contract-shaped response envelope. The default server source is empty unless `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` is configured. |
| `isphere_search_contacts` | log-backed message JIDs | Search contact candidates extracted from sender/receiver bare JIDs in the configured message source. |
| `isphere_search_groups` | log-backed groupchat JIDs | Search group candidates extracted from groupchat/conference/MUC bare JIDs in the configured message source. |
| `isphere_search_contacts` | log-backed message JIDs plus optional MsgLib display enrichment | Search contact candidates extracted from sender/receiver bare JIDs in the configured message source, with deterministic exact-match-first ranking, case-insensitive de-duplication, and preserved `source`/`raw_ref`. |
| `isphere_search_groups` | log-backed groupchat JIDs plus optional MsgLib display enrichment | Search group candidates extracted from groupchat/conference/MUC bare JIDs in the configured message source, with deterministic exact-match-first ranking, case-insensitive de-duplication, and preserved `source`/`raw_ref`. |
| `isphere_receive_files` | log-backed file-transfer messages | List file metadata candidates extracted from file-transfer message bodies. Download/cache mapping is not implemented yet. |
Allowed parameters:
@@ -274,7 +274,7 @@ The current runbook verifies eight tools:
- `isphere_search_groups`
- `isphere_receive_files`
The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_messages` now exposes contract-facing fields for digital-employee consumption while preserving older aliases for compatibility, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args and can optionally enrich display metadata from `MsgLibReadSidecar`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation; download/cache mapping remains deferred.
The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_messages` now exposes contract-facing fields for digital-employee consumption while preserving older aliases for compatibility, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args, rank exact matches before prefix/substring matches, collapse case-insensitive duplicates across log-derived candidates and optional `MsgLibReadSidecar` display metadata, and preserve `source`/`raw_ref`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation; download/cache mapping remains deferred.
Core business tools for contacts, groups, messages, and files are defined in `docs/mcp-core-tools-contract.md`. Send capabilities enter the roadmap through that contract after source discovery, connector selection, preview metadata, execution metadata, and audit records are in place.

View File

@@ -18,8 +18,8 @@ Schema notes: `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md`
| MCP tool | Primary source | Fallback source | Evidence file | Decision status | Next implementation slice |
| --- | --- | --- | --- | --- | --- |
| `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `<message>` stanzas via `internal/isphere.EncryptedPacketLogSource`; configured by `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` | copied `MsgLib.db` message tables through x86 read-only sidecar `list_messages` selected explicitly by `source_preference="msglib_readonly"`; decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence`; `docs/source-discovery/2026-07-10-dotnet-wrapper-static-analysis.md`; `docs/source-discovery/2026-07-10-db-backed-receive-source-design.md`; `docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md` | C20 supports and validates the current safe receive-message contract args; C32 wires optional MsgLib display metadata into receive-message `sender_name` and `conversation.display_name`; C33 proves this path through a real copied-DB MCP smoke; C35 maps MsgLib message tables; C36 adds metadata-only `message_sources`; C37 defines bounded `list_messages` design; C38 implements sidecar/Go-wrapper `list_messages` with sanitized copied-DB verification; C39 adds a Go adapter from `ListMessages` to the receive-message domain model; C40 adds tool-level explicit `msglib_readonly` source selection; C41 wires env-configured MsgLib receive source and optional copied-DB smoke; R1 precheck keeps `auto` PacketReader/log-backed until a future reconciliation helper is implemented | future receive reconciliation helper after R2/R3 |
| `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `TD_Roster`, `TD_CustomEffigy`, `tblRecent`, `tblChatLevel`, `tblPersonMsg`; decrypted roster/contact stanzas from `Smark.SendReceive`; UIA helper source if display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md` | C34 documents how to enable optional MsgLib contact display enrichment and verify sanitized MCP output; search enrichment remains available | R2 contact search quality hardening |
| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `tblMsgGroupPersonMsg`, `TD_WorkGroupAuth`, `tblRecent`; UIA helper source if group display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C34 documents how to enable optional MsgLib group display enrichment and verify sanitized MCP output; search enrichment remains available | R2 group search quality hardening |
| `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `TD_Roster`, `TD_CustomEffigy`, `tblRecent`, `tblChatLevel`, `tblPersonMsg`; decrypted roster/contact stanzas from `Smark.SendReceive`; UIA helper source if display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; `internal/isphere/contacts_test.go`; `internal/tools/isphere_contacts_test.go` | C34 documents optional MsgLib contact display enrichment; R2 adds deterministic exact-match-first ranking, case-insensitive de-duplication across log/MsgLib candidates, and preserves `source`/`raw_ref` | core contact search complete; optional richer fields later |
| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `tblMsgGroupPersonMsg`, `TD_WorkGroupAuth`, `tblRecent`; UIA helper source if group display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; `internal/isphere/groups_test.go`; `internal/tools/isphere_groups_test.go`; C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C34 documents optional MsgLib group display enrichment; R2 adds deterministic exact-match-first ranking, case-insensitive de-duplication across log/MsgLib candidates, and preserves `source`/`raw_ref` | core group search complete; optional member/owner hierarchy later |
| `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas via `internal/isphere.ListFilesFromMessages` and `isphere_receive_files` list mode; configured PacketReader file or directory source; `zyl\importal\<hash>` cache entries remain unlinked | decrypted `Smark.SendReceive` and `SaveToDB` traces for file-reference reconciliation; future UIA/client connector for download | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#c12-file-transfer-evidence-precheck` | C22 verifies safe file-list contract args; no log-to-cache/download mapping yet | R3 receive-file download mapping precheck |
| `isphere_send_message` | none validated yet | existing bridge/API/local service preferred; UIA write connector only after internal-sandbox action evidence; network/protocol connector only after protocol discovery | `docs/source-discovery/2026-07-10-send-message-source-precheck.md` | C15 blocked: contract exists, but no committed bridge/API/local service, helper write op, UIA action chain, or protocol connector is validated | R5 send-message connector discovery |
| `isphere_send_file` | none validated yet | depends on send-message connector plus outbound file/upload evidence | `docs/source-discovery/2026-07-10-send-message-source-precheck.md` | blocked behind `isphere_send_message` connector selection and file upload/source policy | R7 send-file connector discovery |
@@ -39,4 +39,4 @@ Start Stage C with a narrow log-backed `isphere_receive_messages` source abstrac
## Deferred source work
`MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, C29 adds bounded `display_entities` extraction, C30 wires it as optional contact/group MCP enrichment, C31 proves real copied-DB MCP enrichment with sanitized output, C32 reuses it for receive-message display fields, C33 proves that receive display path through a real copied-DB MCP smoke without printing entity values, C34 documents the operator setup/verification path, C35 maps MsgLib message tables to receive-message contract fields without reading row values, C36 adds metadata-only `message_sources` readiness, C37 defines the bounded DB-backed receive-source design, C38 implements sidecar/Go-wrapper `list_messages` with sanitized verification, C39 adds a Go adapter from MsgLib list results to the receive-message domain model, C40 adds explicit tool-level `msglib_readonly` source selection, C41 wires env-configured explicit MsgLib receive with optional sanitized smoke, and R1 documents source reconciliation keys. The active roadmap is `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`; next step is R2 contact/group search quality hardening.
`MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, C29 adds bounded `display_entities` extraction, C30 wires it as optional contact/group MCP enrichment, C31 proves real copied-DB MCP enrichment with sanitized output, C32 reuses it for receive-message display fields, C33 proves that receive display path through a real copied-DB MCP smoke without printing entity values, C34 documents the operator setup/verification path, C35 maps MsgLib message tables to receive-message contract fields without reading row values, C36 adds metadata-only `message_sources` readiness, C37 defines the bounded DB-backed receive-source design, C38 implements sidecar/Go-wrapper `list_messages` with sanitized verification, C39 adds a Go adapter from MsgLib list results to the receive-message domain model, C40 adds explicit tool-level `msglib_readonly` source selection, C41 wires env-configured explicit MsgLib receive with optional sanitized smoke, R1 documents source reconciliation keys, and R2 hardens contact/group search ranking and de-duplication. The active roadmap is `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`; next step is R3 receive-file download mapping precheck.

View File

@@ -25,8 +25,8 @@
| Business goal | Current capability | Remaining blockers | Next node |
| --- | --- | --- | --- |
| Search contacts | Registered MCP tool; log/JID candidates; optional copied-DB MsgLib display enrichment. | Result quality hardening: de-duplication, ranking, stable IDs, optional richer fields. | R2 |
| Search groups | Registered MCP tool; groupchat/conference candidates; optional copied-DB MsgLib display enrichment. | Group member count, owner refs, nested/group hierarchy quality. | R2 |
| Search contacts | Registered MCP tool; deterministic exact-match ranking and case-insensitive de-duplication across log/JID candidates and optional copied-DB MsgLib display enrichment. | Optional richer fields such as department/title remain future enrichment, not a core search blocker. | complete for core search; optional enrichment later |
| Search groups | Registered MCP tool; deterministic exact-match ranking and case-insensitive de-duplication across groupchat/conference candidates and optional copied-DB MsgLib display enrichment. | Optional member count, owner refs, and nested/group hierarchy remain future enrichment, not a core search blocker. | complete for core search; optional enrichment later |
| Receive messages | PacketReader source works; copied `MsgLib.db` explicit receive works with `source_preference="msglib_readonly"`; R1 precheck says default should remain PacketReader until a future reconciliation helper is implemented. | Future reconciliation helper, source confidence scoring, pagination policy. | future receive helper after R2/R3 |
| Send messages | Contract exists; no validated write connector. | Need connector discovery, preview-only path, idempotency and audit before production send. | R5 then R6 |
| Receive files | List mode exists from message-derived file metadata. | Need message-to-cache/download mapping and output-dir download behavior. | R3 then R4 |
@@ -150,7 +150,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-msglib-mcp-en
**Steps:**
- [ ] **Step 1: Add failing contact ranking test**
- [x] **Step 1: Add failing contact ranking test**
Target behavior:
@@ -168,11 +168,11 @@ go test ./internal/isphere -run TestSearchContactsRanksExactAndDeduplicates -v
Expected before implementation: FAIL because ranking/dedup behavior is not implemented.
- [ ] **Step 2: Implement contact ranking/dedup**
- [x] **Step 2: Implement contact ranking/dedup**
Implement minimal deterministic ranking in `internal/isphere/contacts.go`.
- [ ] **Step 3: Add failing group ranking test**
- [x] **Step 3: Add failing group ranking test**
Run:
@@ -182,7 +182,7 @@ go test ./internal/isphere -run TestSearchGroupsRanksExactAndDeduplicates -v
Expected before implementation: FAIL because ranking/dedup behavior is not implemented.
- [ ] **Step 4: Implement group ranking/dedup**
- [x] **Step 4: Implement group ranking/dedup**
Implement minimal deterministic ranking in `internal/isphere/groups.go`.
@@ -198,6 +198,14 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1
- Search contacts and search groups produce deterministic ranked results.
- No send behavior is introduced.
**R2 Result:**
- Added failing-then-passing tests for contact and group exact-match ranking and case-insensitive de-duplication in `internal/isphere`.
- Added failing-then-passing MCP tool tests proving optional MsgLib display entities and log-derived fallback entries merge into one de-duplicated result while preserving `source` and `raw_ref`.
- Implemented deterministic ranking: exact ID/display/account match first, prefix match second, substring match after that, then stable case-insensitive ID ordering.
- No send-message, send-file, file-download, hook, DB-write, or production side-effect behavior was introduced.
- Decision: next business node is R3 receive-file download mapping precheck.
---
### R3: Receive-file download mapping precheck

View File

@@ -30,24 +30,32 @@ func SearchContactsFromMessages(messages []Message, query SearchContactsQuery) S
addMessageContact(unique, message.ReceiverID)
}
queryText := strings.ToLower(strings.TrimSpace(query.Query))
ids := make([]string, 0, len(unique))
for id := range unique {
if queryText == "" || strings.Contains(strings.ToLower(id), queryText) || strings.Contains(strings.ToLower(unique[id].DisplayName), queryText) {
ids = append(ids, id)
queryText := normalizedSearchText(query.Query)
contacts := make([]Contact, 0, len(unique))
for _, contact := range unique {
if contactMatchesQuery(contact, queryText) {
contacts = append(contacts, contact)
}
}
sort.Strings(ids)
sort.Slice(contacts, func(i, j int) bool {
leftRank := contactSearchRank(contacts[i], queryText)
rightRank := contactSearchRank(contacts[j], queryText)
if leftRank != rightRank {
return leftRank < rightRank
}
leftID := strings.ToLower(contacts[i].ContactID)
rightID := strings.ToLower(contacts[j].ContactID)
if leftID != rightID {
return leftID < rightID
}
return contacts[i].ContactID < contacts[j].ContactID
})
limit := query.Limit
if limit <= 0 || limit > len(ids) {
limit = len(ids)
if limit <= 0 || limit > len(contacts) {
limit = len(contacts)
}
contacts := make([]Contact, 0, limit)
for _, id := range ids[:limit] {
contacts = append(contacts, unique[id])
}
return SearchContactsResult{Contacts: contacts}
return SearchContactsResult{Contacts: contacts[:limit]}
}
func addMessageContact(unique map[string]Contact, id string) {
@@ -55,10 +63,11 @@ func addMessageContact(unique map[string]Contact, id string) {
if trimmed == "" {
return
}
if _, exists := unique[trimmed]; exists {
key := strings.ToLower(trimmed)
if _, exists := unique[key]; exists {
return
}
unique[trimmed] = Contact{
unique[key] = Contact{
ContactID: trimmed,
DisplayName: trimmed,
Account: trimmed,
@@ -67,3 +76,38 @@ func addMessageContact(unique map[string]Contact, id string) {
RawRef: "message_jid",
}
}
func normalizedSearchText(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func contactMatchesQuery(contact Contact, queryText string) bool {
if queryText == "" {
return true
}
return strings.Contains(strings.ToLower(contact.ContactID), queryText) ||
strings.Contains(strings.ToLower(contact.DisplayName), queryText) ||
strings.Contains(strings.ToLower(contact.Account), queryText)
}
func contactSearchRank(contact Contact, queryText string) int {
if queryText == "" {
return 0
}
values := []string{
strings.ToLower(contact.ContactID),
strings.ToLower(contact.DisplayName),
strings.ToLower(contact.Account),
}
for _, value := range values {
if value == queryText {
return 0
}
}
for _, value := range values {
if strings.HasPrefix(value, queryText) {
return 1
}
}
return 2
}

View File

@@ -20,3 +20,19 @@ func TestSearchContactsFromMessagesMatchesBareJIDs(t *testing.T) {
t.Fatalf("SearchContactsFromMessages() = %#v, want %#v", got, want)
}
}
func TestSearchContactsRanksExactAndDeduplicates(t *testing.T) {
messages := []Message{
{ID: "msg-1", SenderID: "zzz-target@imopenfire1-lanzhou", ReceiverID: "target@imopenfire1-lanzhou"},
{ID: "msg-2", SenderID: "TARGET@imopenfire1-lanzhou", ReceiverID: "other@imopenfire1-lanzhou"},
}
got := SearchContactsFromMessages(messages, SearchContactsQuery{Query: "target@imopenfire1-lanzhou", Limit: 10})
want := SearchContactsResult{Contacts: []Contact{
{ContactID: "target@imopenfire1-lanzhou", DisplayName: "target@imopenfire1-lanzhou", Account: "target@imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "message_jid"},
{ContactID: "zzz-target@imopenfire1-lanzhou", DisplayName: "zzz-target@imopenfire1-lanzhou", Account: "zzz-target@imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "message_jid"},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("SearchContactsFromMessages() = %#v, want %#v", got, want)
}
}

View File

@@ -38,24 +38,32 @@ func SearchGroupsFromMessages(messages []Message, query SearchGroupsQuery) Searc
}
}
queryText := strings.ToLower(strings.TrimSpace(query.Query))
ids := make([]string, 0, len(unique))
for id := range unique {
if queryText == "" || strings.Contains(strings.ToLower(id), queryText) || strings.Contains(strings.ToLower(unique[id].DisplayName), queryText) {
ids = append(ids, id)
queryText := normalizedSearchText(query.Query)
groups := make([]Group, 0, len(unique))
for _, group := range unique {
if groupMatchesQuery(group, queryText) {
groups = append(groups, group)
}
}
sort.Strings(ids)
sort.Slice(groups, func(i, j int) bool {
leftRank := groupSearchRank(groups[i], queryText)
rightRank := groupSearchRank(groups[j], queryText)
if leftRank != rightRank {
return leftRank < rightRank
}
leftID := strings.ToLower(groups[i].GroupID)
rightID := strings.ToLower(groups[j].GroupID)
if leftID != rightID {
return leftID < rightID
}
return groups[i].GroupID < groups[j].GroupID
})
limit := query.Limit
if limit <= 0 || limit > len(ids) {
limit = len(ids)
if limit <= 0 || limit > len(groups) {
limit = len(groups)
}
groups := make([]Group, 0, limit)
for _, id := range ids[:limit] {
groups = append(groups, unique[id])
}
return SearchGroupsResult{Groups: groups}
return SearchGroupsResult{Groups: groups[:limit]}
}
func addMessageGroup(unique map[string]Group, id string) {
@@ -63,10 +71,11 @@ func addMessageGroup(unique map[string]Group, id string) {
if trimmed == "" || !isLikelyGroupJID(trimmed) {
return
}
if _, exists := unique[trimmed]; exists {
key := strings.ToLower(trimmed)
if _, exists := unique[key]; exists {
return
}
unique[trimmed] = Group{
unique[key] = Group{
GroupID: trimmed,
DisplayName: trimmed,
Source: "local_readonly",
@@ -79,3 +88,32 @@ func isLikelyGroupJID(id string) bool {
lower := strings.ToLower(strings.TrimSpace(id))
return strings.Contains(lower, "conference") || strings.Contains(lower, "muc") || strings.Contains(lower, "group")
}
func groupMatchesQuery(group Group, queryText string) bool {
if queryText == "" {
return true
}
return strings.Contains(strings.ToLower(group.GroupID), queryText) ||
strings.Contains(strings.ToLower(group.DisplayName), queryText)
}
func groupSearchRank(group Group, queryText string) int {
if queryText == "" {
return 0
}
values := []string{
strings.ToLower(group.GroupID),
strings.ToLower(group.DisplayName),
}
for _, value := range values {
if value == queryText {
return 0
}
}
for _, value := range values {
if strings.HasPrefix(value, queryText) {
return 1
}
}
return 2
}

View File

@@ -20,3 +20,20 @@ func TestSearchGroupsFromMessagesMatchesGroupchatJIDs(t *testing.T) {
t.Fatalf("SearchGroupsFromMessages() = %#v, want %#v", got, want)
}
}
func TestSearchGroupsRanksExactAndDeduplicates(t *testing.T) {
messages := []Message{
{ID: "msg-1", ConversationType: "groupchat", SenderID: "zzz-project@conference.imopenfire1-lanzhou", ReceiverID: "member@imopenfire1-lanzhou"},
{ID: "msg-2", ConversationType: "groupchat", SenderID: "project@conference.imopenfire1-lanzhou", ReceiverID: "member@imopenfire1-lanzhou"},
{ID: "msg-3", ConversationType: "groupchat", SenderID: "PROJECT@conference.imopenfire1-lanzhou", ReceiverID: "member@imopenfire1-lanzhou"},
}
got := SearchGroupsFromMessages(messages, SearchGroupsQuery{Query: "project@conference.imopenfire1-lanzhou", Limit: 10})
want := SearchGroupsResult{Groups: []Group{
{GroupID: "project@conference.imopenfire1-lanzhou", DisplayName: "project@conference.imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "groupchat_jid"},
{GroupID: "zzz-project@conference.imopenfire1-lanzhou", DisplayName: "zzz-project@conference.imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "groupchat_jid"},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("SearchGroupsFromMessages() = %#v, want %#v", got, want)
}
}

View File

@@ -2,6 +2,7 @@ package tools
import (
"context"
"sort"
"strings"
"isphere-ai-bridge/internal/isphere"
@@ -73,7 +74,7 @@ func groupsFromDisplayEntities(entities []msglib.DisplayEntity) []isphere.Group
return groups
}
func mergeContacts(primary []isphere.Contact, fallback []isphere.Contact, limit int) isphere.SearchContactsResult {
func mergeContacts(primary []isphere.Contact, fallback []isphere.Contact, query string, limit int) isphere.SearchContactsResult {
seen := map[string]bool{}
contacts := make([]isphere.Contact, 0, len(primary)+len(fallback))
for _, contact := range append(primary, fallback...) {
@@ -83,14 +84,15 @@ func mergeContacts(primary []isphere.Contact, fallback []isphere.Contact, limit
}
seen[strings.ToLower(id)] = true
contacts = append(contacts, contact)
if limit > 0 && len(contacts) >= limit {
break
}
}
sortContactsForQuery(contacts, query)
if limit > 0 && len(contacts) > limit {
contacts = contacts[:limit]
}
return isphere.SearchContactsResult{Contacts: contacts}
}
func mergeGroups(primary []isphere.Group, fallback []isphere.Group, limit int) isphere.SearchGroupsResult {
func mergeGroups(primary []isphere.Group, fallback []isphere.Group, query string, limit int) isphere.SearchGroupsResult {
seen := map[string]bool{}
groups := make([]isphere.Group, 0, len(primary)+len(fallback))
for _, group := range append(primary, fallback...) {
@@ -100,9 +102,87 @@ func mergeGroups(primary []isphere.Group, fallback []isphere.Group, limit int) i
}
seen[strings.ToLower(id)] = true
groups = append(groups, group)
if limit > 0 && len(groups) >= limit {
break
}
}
sortGroupsForQuery(groups, query)
if limit > 0 && len(groups) > limit {
groups = groups[:limit]
}
return isphere.SearchGroupsResult{Groups: groups}
}
func sortContactsForQuery(contacts []isphere.Contact, query string) {
queryText := strings.ToLower(strings.TrimSpace(query))
sort.Slice(contacts, func(i, j int) bool {
leftRank := contactRankForQuery(contacts[i], queryText)
rightRank := contactRankForQuery(contacts[j], queryText)
if leftRank != rightRank {
return leftRank < rightRank
}
leftID := strings.ToLower(contacts[i].ContactID)
rightID := strings.ToLower(contacts[j].ContactID)
if leftID != rightID {
return leftID < rightID
}
return contacts[i].ContactID < contacts[j].ContactID
})
}
func contactRankForQuery(contact isphere.Contact, queryText string) int {
if queryText == "" {
return 0
}
values := []string{
strings.ToLower(contact.ContactID),
strings.ToLower(contact.DisplayName),
strings.ToLower(contact.Account),
}
for _, value := range values {
if value == queryText {
return 0
}
}
for _, value := range values {
if strings.HasPrefix(value, queryText) {
return 1
}
}
return 2
}
func sortGroupsForQuery(groups []isphere.Group, query string) {
queryText := strings.ToLower(strings.TrimSpace(query))
sort.Slice(groups, func(i, j int) bool {
leftRank := groupRankForQuery(groups[i], queryText)
rightRank := groupRankForQuery(groups[j], queryText)
if leftRank != rightRank {
return leftRank < rightRank
}
leftID := strings.ToLower(groups[i].GroupID)
rightID := strings.ToLower(groups[j].GroupID)
if leftID != rightID {
return leftID < rightID
}
return groups[i].GroupID < groups[j].GroupID
})
}
func groupRankForQuery(group isphere.Group, queryText string) int {
if queryText == "" {
return 0
}
values := []string{
strings.ToLower(group.GroupID),
strings.ToLower(group.DisplayName),
}
for _, value := range values {
if value == queryText {
return 0
}
}
for _, value := range values {
if strings.HasPrefix(value, queryText) {
return 1
}
}
return 2
}

View File

@@ -52,7 +52,7 @@ func RegisterISphereContactToolsWithDisplayEntities(server *mcp.Server, source R
if err != nil {
return nil, nil, err
}
contacts = mergeContacts(contactsFromDisplayEntities(entities), logContacts.Contacts, input.Limit)
contacts = mergeContacts(contactsFromDisplayEntities(entities), logContacts.Contacts, input.Query, input.Limit)
}
return nil, searchContactsResultToMap(contacts, started, time.Now().UTC()), nil
})

View File

@@ -131,6 +131,56 @@ func TestISphereSearchContactsToolUsesInjectedDisplayEntities(t *testing.T) {
}
}
func TestISphereSearchContactsToolRanksExactAndDeduplicatesDisplayEntities(t *testing.T) {
fakeMessages := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-1",
SenderID: "target@imopenfire1-lanzhou",
ReceiverID: "other@imopenfire1-lanzhou",
}}},
}
fakeDisplay := &fakeDisplayEntitySource{entities: []msglib.DisplayEntity{
{EntityType: msglib.EntityTypeContacts, SourceTable: "TD_CustomEffigy", JID: "zzz-target@imopenfire1-lanzhou", DisplayName: "ZZZ Target", Confidence: 0.8},
{EntityType: msglib.EntityTypeContacts, SourceTable: "TD_CustomEffigy", JID: "target@imopenfire1-lanzhou", DisplayName: "Target", Confidence: 0.9},
}}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereContactToolsWithDisplayEntities(server, fakeMessages, fakeDisplay)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchContacts,
Arguments: map[string]any{"query": "target@imopenfire1-lanzhou", "limit": 10},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSearchContacts, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
Contacts []struct {
ContactID string `json:"contact_id"`
Source string `json:"source"`
RawRef string `json:"raw_ref"`
} `json:"contacts"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Contacts) != 2 {
t.Fatalf("contacts = %+v, want exact and substring only", decoded.Contacts)
}
if decoded.Contacts[0].ContactID != "target@imopenfire1-lanzhou" || decoded.Contacts[0].Source != "msglib_readonly" || decoded.Contacts[0].RawRef != "msglib:TD_CustomEffigy" {
t.Fatalf("first contact should be exact MsgLib match with source metadata, got %+v", decoded.Contacts[0])
}
if decoded.Contacts[1].ContactID != "zzz-target@imopenfire1-lanzhou" {
t.Fatalf("second contact should be substring match, got %+v", decoded.Contacts[1])
}
}
func TestISphereSearchContactsToolValidatesContractArgs(t *testing.T) {
fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{

View File

@@ -52,7 +52,7 @@ func RegisterISphereGroupToolsWithDisplayEntities(server *mcp.Server, source Rec
if err != nil {
return nil, nil, err
}
groups = mergeGroups(groupsFromDisplayEntities(entities), logGroups.Groups, input.Limit)
groups = mergeGroups(groupsFromDisplayEntities(entities), logGroups.Groups, input.Query, input.Limit)
}
return nil, searchGroupsResultToMap(groups, started, time.Now().UTC()), nil
})

View File

@@ -135,6 +135,57 @@ func TestISphereSearchGroupsToolUsesInjectedDisplayEntities(t *testing.T) {
}
}
func TestISphereSearchGroupsToolRanksExactAndDeduplicatesDisplayEntities(t *testing.T) {
fakeMessages := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-1",
ConversationType: "groupchat",
SenderID: "target@conference.imopenfire1-lanzhou",
ReceiverID: "member@imopenfire1-lanzhou",
}}},
}
fakeDisplay := &fakeDisplayEntitySource{entities: []msglib.DisplayEntity{
{EntityType: msglib.EntityTypeGroups, SourceTable: "TD_WorkGroupAuth", JID: "zzz-target@conference.imopenfire1-lanzhou", DisplayName: "ZZZ Target Group", Confidence: 0.8},
{EntityType: msglib.EntityTypeGroups, SourceTable: "TD_WorkGroupAuth", JID: "target@conference.imopenfire1-lanzhou", DisplayName: "Target Group", Confidence: 0.9},
}}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereGroupToolsWithDisplayEntities(server, fakeMessages, fakeDisplay)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchGroups,
Arguments: map[string]any{"query": "target@conference.imopenfire1-lanzhou", "limit": 10},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSearchGroups, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
Groups []struct {
GroupID string `json:"group_id"`
Source string `json:"source"`
RawRef string `json:"raw_ref"`
} `json:"groups"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Groups) != 2 {
t.Fatalf("groups = %+v, want exact and substring only", decoded.Groups)
}
if decoded.Groups[0].GroupID != "target@conference.imopenfire1-lanzhou" || decoded.Groups[0].Source != "msglib_readonly" || decoded.Groups[0].RawRef != "msglib:TD_WorkGroupAuth" {
t.Fatalf("first group should be exact MsgLib match with source metadata, got %+v", decoded.Groups[0])
}
if decoded.Groups[1].GroupID != "zzz-target@conference.imopenfire1-lanzhou" {
t.Fatalf("second group should be substring match, got %+v", decoded.Groups[1])
}
}
func TestISphereSearchGroupsToolValidatesContractArgs(t *testing.T) {
fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{