File size: 4,656 Bytes
f51c224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
<?php
defined( 'ABSPATH' ) || exit;

/**
 * Token Log — append-only audit of LLM invocations (Board #16).
 *
 * Each call to an LLM provider (OpenAI today; pluggable) writes one row.
 * The row optionally links to a downstream actor-authored comment via
 * (comment_kind, comment_id) once the future invocation surface ships
 * and starts attributing AI-driven commits to actors.
 *
 * Two consumers:
 *   1. Sara UI — reads usage from the live response (NOT from this table)
 *      to render per-turn + session totals in the bubble. Session total is
 *      JS-only; this table is server-side audit.
 *   2. Board #23 handoff layer — its filter callback queries
 *      has_token_for_comment(kind, comment_id) to resolve operator mode.
 *      Returns true if a row matches, false otherwise. Always deterministic.
 *
 * Doctrine constraints honored:
 *   - No cost calculation. No analytics. No replay.
 *   - Server-side persistence is for cross-session attribution (the #23
 *     hook). The Sara UI counters remain session-only as locked.
 *   - Strictly additive: one new table; no existing table mutated.
 */
class SA_Orch_Token_Log {

    private static function table(): string {
        global $wpdb;
        return $wpdb->prefix . SA_ORCH_DB_PREFIX . 'token_log';
    }

    /**
     * Record one LLM invocation. Returns the new row id, or 0 on failure.
     *
     * @param string $provider  e.g. 'openai'
     * @param string $model     e.g. 'gpt-4o-mini'
     * @param array  $usage     [ prompt_tokens, completion_tokens, total_tokens ]
     * @param int    $user_id   the WP user_id who triggered the call (or 0)
     */
    public static function record( string $provider, string $model, array $usage, int $user_id = 0 ): int {
        if ( $provider === '' ) return 0;
        global $wpdb;
        $ok = $wpdb->insert( self::table(), [
            'user_id'           => $user_id > 0 ? $user_id : null,
            'provider'          => $provider,
            'model'             => $model,
            'prompt_tokens'     => isset( $usage['prompt_tokens'] )     ? (int) $usage['prompt_tokens']     : 0,
            'completion_tokens' => isset( $usage['completion_tokens'] ) ? (int) $usage['completion_tokens'] : 0,
            'total_tokens'      => isset( $usage['total_tokens'] )      ? (int) $usage['total_tokens']      : 0,
            'comment_kind'      => null,
            'comment_id'        => null,
            'invoked_at'        => current_time( 'mysql', true ),
        ] );
        return $ok !== false ? (int) $wpdb->insert_id : 0;
    }

    /**
     * Link a previously-recorded token row to the comment it produced.
     * Called by the future invocation surface when an actor-attributed
     * comment is committed from a Sara draft.
     *
     * Idempotent on the row's identity — re-linking just updates the row.
     */
    public static function link_to_comment( int $log_id, string $kind, int $comment_id ): bool {
        if ( $log_id <= 0 || $comment_id <= 0 ) return false;
        $kind = sanitize_key( $kind );
        if ( $kind === '' ) return false;

        global $wpdb;
        $ok = $wpdb->update(
            self::table(),
            [
                'comment_kind' => $kind,
                'comment_id'   => $comment_id,
            ],
            [ 'id' => $log_id ]
        );
        return $ok !== false;
    }

    /**
     * Filter callback for Board #23's 'sa_orch_handoff_has_llm_token'.
     * Returns true if at least one token row is linked to (kind, comment_id),
     * false otherwise. Always deterministic — no nulls.
     */
    public static function has_token_for_comment( string $kind, int $comment_id ): bool {
        if ( $comment_id <= 0 ) return false;
        $kind = sanitize_key( $kind );
        if ( $kind === '' ) return false;

        global $wpdb;
        $count = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM " . self::table() . "
             WHERE comment_kind = %s AND comment_id = %d",
            $kind,
            $comment_id
        ) );
        return $count > 0;
    }

    /**
     * Bridge to the Board #23 hook. Hooked at plugin load via add_filter.
     * Replaces the default 'unknown' (null) with a deterministic boolean.
     *
     * @param mixed  $default     The default value (typically null).
     * @param string $kind        Comment kind constant.
     * @param int    $comment_id  Comment id.
     * @return bool
     */
    public static function answer_handoff_filter( $default, string $kind, int $comment_id ): bool {
        return self::has_token_for_comment( $kind, $comment_id );
    }
}