• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

marscoin / martianrepublic / 29796968107

21 Jul 2026 02:52AM UTC coverage: 10.965% (-0.03%) from 10.997%
29796968107

push

github

Martian Congress
fix: Drop dynamic DateInterval->w in time_elapsed_string

PHPStan (bumped to larastan 3.10 in the Laravel 12 upgrade) flagged the
$diff->w dynamic property on DateInterval, and its new error-message
format no longer matched the phpstan-baseline entry, failing the Test
Coverage job on both counts. Derive weeks from days in local vars
instead (also removes a PHP 8.2 dynamic-property deprecation) and drop
the now-stale baseline entry. Output unchanged; phpstan clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

0 of 15 new or added lines in 1 file covered. (0.0%)

238 existing lines in 4 files now uncovered.

665 of 6065 relevant lines covered (10.96%)

1.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

28.4
/app/Http/Controllers/AiHelperController.php
1
<?php
2

3
namespace App\Http\Controllers;
4

5
use Illuminate\Http\Request;
6
use Illuminate\Support\Facades\Http;
7
use Symfony\Component\HttpFoundation\StreamedResponse;
8

9
class AiHelperController extends Controller
10
{
11
    private function getSystemPrompt(): string
12
    {
13
        return <<<'PROMPT'
14
You are Olympus, the AI guide of the Martian Republic — a blockchain-based governance platform for Mars built on the Marscoin network.
15

16
Your role: Help citizens and newcomers understand how the Republic works. Be warm, concise, and jargon-free. You embody civic pride and the pioneer spirit of Mars settlement.
17

18
You have access to a search tool to look up Academy articles and site content. Use it when you need specific details about governance, voting, Mars time, or any topic covered in the Academy. Don't guess — search first if unsure.
19

20
Key knowledge:
21
- Direct democracy on the Marscoin blockchain
22
- 4 governance tiers: Signal, Operational, Legislative, Constitutional
23
- CoinShuffle for anonymous voting (cryptographic ballot mixing)
24
- Citizenship requires registration, 2FA, and an HD wallet ("The Forge")
25
- Mars time: 1 sol = 24h 39m 35s, Darian calendar (24 months, 668.6 sols/year)
26
- MARS cryptocurrency: proof-of-work, merge-mined with Litecoin
27

28
Academy articles:
29
- /academy/governance-and-voting — 4-tier governance system
30
- /academy/coinshuffle-secret-ballots — anonymous voting protocol
31
- /academy/mars-timekeeping — sols, MTC, Darian calendar
32
- /academy/the-public-good — political philosophy
33

34
Rules:
35
- Keep answers under 150 words unless asked for detail
36
- Never make up information — use the search tool if unsure
37
- Link to Academy articles when relevant (use markdown links)
38
- No price speculation or investment advice
39
- Be encouraging — every citizen matters
40
PROMPT;
41
    }
42

43
    private function getTools(): array
1✔
44
    {
45
        return [
1✔
46
            [
1✔
47
                'type' => 'function',
1✔
48
                'function' => [
1✔
49
                    'name' => 'search_content',
1✔
50
                    'description' => 'Search the Martian Republic Academy articles and site content for information. Use this to find accurate details about governance, voting, Mars time, citizenship, proposals, or any topic.',
1✔
51
                    'parameters' => [
1✔
52
                        'type' => 'object',
1✔
53
                        'properties' => [
1✔
54
                            'query' => [
1✔
55
                                'type' => 'string',
1✔
56
                                'description' => 'Search query — keywords or a question to search for in Academy articles',
1✔
57
                            ],
1✔
58
                        ],
1✔
59
                        'required' => ['query'],
1✔
60
                    ],
1✔
61
                ],
1✔
62
            ],
1✔
63
        ];
1✔
64
    }
65

66
    private function executeSearch(string $query): string
×
67
    {
68
        $searchPaths = [
×
69
            resource_path('views/academy'),
×
70
            resource_path('views/academy/articles'),
×
71
        ];
×
72

73
        $keywords = array_filter(
×
74
            explode(' ', strtolower(preg_replace('/[^a-zA-Z0-9\s]/', '', $query))),
×
75
            fn ($w) => strlen($w) > 2
×
76
        );
×
77

78
        if (empty($keywords)) {
×
79
            return 'No results found.';
×
80
        }
81

82
        $results = [];
×
83

84
        foreach ($searchPaths as $path) {
×
85
            $files = glob($path.'/*.blade.php') ?: [];
×
86
            foreach ($files as $file) {
×
87
                $content = file_get_contents($file);
×
88
                $text = strip_tags($content);
×
89
                $text = preg_replace('/\s+/', ' ', $text);
×
90
                $lower = strtolower($text);
×
91

92
                $score = 0;
×
93
                foreach ($keywords as $kw) {
×
94
                    $score += substr_count($lower, $kw);
×
95
                }
96

97
                if ($score > 0) {
×
98
                    $slug = basename($file, '.blade.php');
×
99
                    // Get multiple excerpts around matches
100
                    $excerpts = [];
×
101
                    foreach ($keywords as $kw) {
×
102
                        $offset = 0;
×
103
                        while (($pos = strpos($lower, $kw, $offset)) !== false && count($excerpts) < 3) {
×
104
                            $start = max(0, $pos - 80);
×
105
                            $excerpts[] = '...'.trim(substr($text, $start, 250)).'...';
×
106
                            $offset = $pos + strlen($kw) + 100;
×
107
                        }
108
                    }
109
                    $results[] = [
×
110
                        'score' => $score,
×
111
                        'slug' => $slug,
×
112
                        'url' => '/academy/'.$slug,
×
113
                        'excerpts' => array_unique(array_slice($excerpts, 0, 3)),
×
114
                    ];
×
115
                }
116
            }
117
        }
118

119
        usort($results, fn ($a, $b) => $b['score'] <=> $a['score']);
×
120
        $top = array_slice($results, 0, 3);
×
121

122
        if (empty($top)) {
×
123
            return 'No matching Academy articles found for: '.$query;
×
124
        }
125

126
        $output = "Search results for \"{$query}\":\n\n";
×
127
        foreach ($top as $r) {
×
128
            $output .= "--- {$r['url']} (relevance: {$r['score']}) ---\n";
×
129
            foreach ($r['excerpts'] as $ex) {
×
130
                $output .= $ex."\n";
×
131
            }
132
            $output .= "\n";
×
133
        }
134

135
        return $output;
×
136
    }
137

138
    public function chat(Request $request)
3✔
139
    {
140
        $request->validate([
3✔
141
            'messages' => 'required|array|min:1',
3✔
142
            'messages.*.role' => 'required|in:user,assistant',
3✔
143
            'messages.*.content' => 'required|string|max:2000',
3✔
144
        ]);
3✔
145

146
        $userMessages = array_slice($request->input('messages'), -10);
1✔
147
        $apiKey = config('services.openrouter.api_key');
1✔
148
        $model = config('services.openrouter.model', 'qwen/qwen3-235b-a22b-2507');
1✔
149

150
        $messages = array_merge(
1✔
151
            [['role' => 'system', 'content' => $this->getSystemPrompt()]],
1✔
152
            $userMessages
1✔
153
        );
1✔
154

155
        // Step 1: Non-streaming call to check for tool use
156
        try {
157
            $firstResponse = Http::withHeaders([
1✔
158
                'Authorization' => 'Bearer '.$apiKey,
1✔
159
                'HTTP-Referer' => 'https://martianrepublic.org',
1✔
160
                'X-Title' => 'Martian Republic',
1✔
161
            ])->timeout(20)->connectTimeout(5)->retry(1, 500)->post('https://openrouter.ai/api/v1/chat/completions', [
1✔
162
                'model' => $model,
1✔
163
                'messages' => $messages,
1✔
164
                'tools' => $this->getTools(),
1✔
165
                'max_tokens' => 500,
1✔
166
                'temperature' => 0.7,
1✔
167
            ]);
1✔
UNCOV
168
        } catch (\Illuminate\Http\Client\ConnectionException $e) {
×
UNCOV
169
            \Log::warning('OpenRouter timeout', ['error' => $e->getMessage()]);
×
UNCOV
170
            return response()->json([
×
UNCOV
171
                'error' => 'AI service is temporarily unavailable. Please try again in a moment.',
×
UNCOV
172
            ], 503);
×
173
        }
174

175
        $firstData = $firstResponse->json();
1✔
176
        $choice = $firstData['choices'][0] ?? null;
1✔
177

178
        if (! $choice) {
1✔
179
            return response()->json(['error' => 'No response from AI'], 502);
1✔
180
        }
181

182
        // Step 2: If tool call, execute and do a second call with results
183
        if (($choice['finish_reason'] ?? '') === 'tool_calls' ||
×
184
            ! empty($choice['message']['tool_calls'])) {
×
185

186
            $toolCalls = $choice['message']['tool_calls'] ?? [];
×
UNCOV
187
            $assistantMsg = $choice['message'];
×
188
            $messages[] = $assistantMsg;
×
189

190
            foreach ($toolCalls as $tc) {
×
191
                $args = json_decode($tc['function']['arguments'] ?? '{}', true);
×
192
                $searchQuery = $args['query'] ?? '';
×
UNCOV
193
                $result = $this->executeSearch($searchQuery);
×
194

UNCOV
195
                $messages[] = [
×
196
                    'role' => 'tool',
×
UNCOV
197
                    'tool_call_id' => $tc['id'],
×
UNCOV
198
                    'content' => $result,
×
UNCOV
199
                ];
×
200
            }
201

202
            // Step 3: Stream the final response with tool results
203
            return $this->streamResponse($messages, $apiKey, $model);
×
204
        }
205

206
        // No tool call — stream directly from the first response content
207
        $content = $choice['message']['content'] ?? '';
×
208

209
        return new StreamedResponse(function () use ($content) {
×
210
            echo 'data: '.json_encode(['content' => $content])."\n\n";
×
211
            echo "data: [DONE]\n\n";
×
212
            ob_flush();
×
UNCOV
213
            flush();
×
UNCOV
214
        }, 200, [
×
215
            'Content-Type' => 'text/event-stream',
×
UNCOV
216
            'Cache-Control' => 'no-cache',
×
217
            'Connection' => 'keep-alive',
×
218
            'X-Accel-Buffering' => 'no',
×
219
        ]);
×
220
    }
221

222
    private function streamResponse(array $messages, string $apiKey, string $model): StreamedResponse
×
223
    {
224
        return new StreamedResponse(function () use ($messages, $apiKey, $model) {
×
225
            $response = Http::withHeaders([
×
226
                'Authorization' => 'Bearer '.$apiKey,
×
227
                'HTTP-Referer' => 'https://martianrepublic.org',
×
228
                'X-Title' => 'Martian Republic',
×
229
            ])->withOptions([
×
230
                'stream' => true,
×
UNCOV
231
            ])->post('https://openrouter.ai/api/v1/chat/completions', [
×
232
                'model' => $model,
×
233
                'messages' => $messages,
×
234
                'stream' => true,
×
235
                'max_tokens' => 500,
×
236
                'temperature' => 0.7,
×
237
            ]);
×
238

UNCOV
239
            $body = $response->getBody();
×
240
            while (! $body->eof()) {
×
UNCOV
241
                $line = '';
×
242
                while (! $body->eof()) {
×
243
                    $char = $body->read(1);
×
244
                    if ($char === "\n") {
×
245
                        break;
×
246
                    }
247
                    $line .= $char;
×
248
                }
249
                $line = trim($line);
×
250
                if (str_starts_with($line, 'data: ')) {
×
251
                    $data = substr($line, 6);
×
252
                    if ($data === '[DONE]') {
×
253
                        echo "data: [DONE]\n\n";
×
254
                        break;
×
255
                    }
UNCOV
256
                    $json = json_decode($data, true);
×
UNCOV
257
                    if ($json && isset($json['choices'][0]['delta']['content'])) {
×
258
                        $token = $json['choices'][0]['delta']['content'];
×
259
                        echo 'data: '.json_encode(['content' => $token])."\n\n";
×
260
                        ob_flush();
×
261
                        flush();
×
262
                    }
263
                }
264
            }
UNCOV
265
        }, 200, [
×
UNCOV
266
            'Content-Type' => 'text/event-stream',
×
UNCOV
267
            'Cache-Control' => 'no-cache',
×
UNCOV
268
            'Connection' => 'keep-alive',
×
UNCOV
269
            'X-Accel-Buffering' => 'no',
×
UNCOV
270
        ]);
×
271
    }
272
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc